feat(repo-hygiene): single pruned walk + dry-run manifest + apply summary - #1023
Conversation
…mary Rework the clean skill's build/caches enumeration and apply flow as one change. #993 — the selective caches/build tiers ran ~10 unpruned full-tree `find` walks per repo; the `! -path` exclusions filtered output but did not `-prune`, so every walk re-descended `.git/`, `node_modules/`, and `.venv/`. A single pruned walk per tier (`clean_enumerate`) now prunes those three trees once and `-print`s all dir-name and file-glob matches, then classifies on the result list. Measured on a large .NET + node repo (Windows/NTFS): one pruned walk incl. `du` sizing ~17s vs a 10-walk unpruned dry-run that exceeded 10 min (killed). #995 — the dry-run now writes a session-scoped manifest (`<class>\t<bytes>\t<relpath>` per eligible target), prints `Manifest: <path>` and `Summary: planned=N bytes=K` so the gate can state reclaimable space, and `--apply --manifest <path>` consumes it with a re-stat + re-classify staleness guard instead of re-walking. Resume = re-run the same command (already-gone entries are idempotent). `--apply` without a manifest builds one then applies it, preserving the standalone CLI contract; `--include-caches` folds the caches tier into the one build manifest (no subprocess). #1002 — each `--apply` ends with `Summary: removed=N failed=M bytes=K` and exits non-zero when failed>0, so a fleet sweep no longer needs per-log grepping. Cross-tier nested-target dedup drops any eligible path under an eligible ancestor so byte totals never double-count. scan.sh keeps the old walk pattern for now, tracked as TODO(#1011). Closes #993 Closes #995 Closes #1002 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016D1oCPX8LaUKnLUi3TXmih
…ding The manifest is a consumed contract (#994 parses it), so assert the exact class<TAB>bytes<TAB>relpath shape per tier rather than substrings, and cover --include-caches folding both a build and a caches entry into one manifest. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016D1oCPX8LaUKnLUi3TXmih
|
Claude finished @kyle-sexton's task in 5m 0s —— View job PR Review
The overall design is solid. The single-pruned-walk architecture, manifest-first flow, nested-path dedup, and batched Security — Medium
abs="$root/$rel"
[[ -e "$abs" ]] || continue
if ! skip="$(clean_target_eligible "$root" "$abs")"; then
In the normal agent flow this requires a crafted manifest — the risk surface is the # After abs="$root/$rel":
[[ "$abs" == "$root/"* ]] || { printf 'Skip (out-of-root): %s\n' "$rel"; continue; }Correctness — MediumStaleness guard has no test case The staleness guard is a first-class design goal (PR description: "a path that became protected since the dry-run is not removed") but neither test file exercises the scenario where a path becomes protected between dry-run and apply. The guard runs in
...would pin the contract and prevent regression if the protection logic is ever restructured. Fix this → Correctness — Low
if ! dotnet clean "$DOTNET_SOLUTION" -v q 2>/dev/null; then
printf 'DRIVER_FAILED: %s\n' "$DOTNET_DRIVER" >&2
fiA If the intent is that driver failures are advisory (non-fatal), that should be documented in the usage comment. If they should be fatal, the fix is to increment Architecture — LowGlobal accumulator variables are not reset between calls CLEAN_CAND_ABS=()
CLEAN_CAND_CLASS=()
CLEAN_PLANNED_COUNT=0
CLEAN_PLANNED_BYTES=0
...These are initialized at source time and accumulate across every Architecture — LowExact-duplicate paths in The nested-path dedup checks Nitpick
--manifest)
MANIFEST_ARG="${2:-}"
shift 2
;;If SummaryThe architecture is well-executed — the single-walk engine and manifest-first flow cleanly close the three linked issues, and the test coverage is strong for the happy-path and dedup contracts. The security finding (path containment in |
|
Claude finished @kyle-sexton's task in 3m 44s —— View job Security Review — PR #1023
Two confirmed security issues in the new manifest consume path ( CRITICAL — Arithmetic injection via manifest
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8c845e5e1d
ℹ️ 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".
`clean-build.sh --apply` ran `dotnet clean <solution>` before removing bin/obj wholesale, but the universal artifact removal already deletes everything the driver would — running it first was pure overhead: a full MSBuild evaluation (minutes on a large solution) that also re-created obj/ evaluation artifacts. One walk + rm is strictly faster and equally complete. Removes the driver and its `Planned: dotnet clean …` (dry-run) / `DRIVER_FAILED:` (apply) output markers, and the now-stale driver references in the config/ecosystem/README docs. Closes #999 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016D1oCPX8LaUKnLUi3TXmih
|
Claude finished @kyle-sexton's task in 1m 58s —— View job PR Review — #1023 (HEAD
|
|
Claude finished @kyle-sexton's task in 3m 6s —— View job Security Review — PR #1023 (HEAD: d8e8c98)
The latest commit ( CRITICAL — Bash arithmetic injection via manifest
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d8e8c98d6d
ℹ️ 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".
The repo's comment-hygiene gate forbids tracker/issue references and TODO markers in code comments; version control, the CHANGELOG, and the tracker own that history. Strips the `(#NNN)` refs and the scan.sh `TODO(#…)` note from the clean scripts and their comments — no behavior change. The scan.sh migration deferral remains tracked by its own issue and this PR. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016D1oCPX8LaUKnLUi3TXmih
|
Claude finished @kyle-sexton's task in 2m 7s —— View job PR Review — #1023 (HEAD
|
|
Claude finished @kyle-sexton's task in 2m 31s —— View job Security Review — PR #1023 (HEAD:
|
|
🔒 merge-drive lane claiming this PR for on-branch fix work this cycle (amendment-round: 16, worker tier). Will fix clear branch-owned CI/bot findings, defer non-DEFER-FORBIDDEN items with reply+resolve, push, then re-check the merge gate. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9a534d2cb5
ℹ️ 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".
…edup The `--apply --manifest <path>` surface is documented and caller-supplied, so `clean_apply_manifest` now treats the manifest as untrusted input: - Containment: an entry whose repo-relative path is absolute or traverses a parent (`..`, either separator) is rejected before any stat/rm and counted as a failure — an entry like `../outside` can no longer make `rm -rf` escape the repository. - Byte field as data: the size field is validated as an unsigned decimal before it reaches Bash arithmetic (which evaluates array subscripts recursively), so a crafted value cannot execute embedded command substitution. - Fail closed: `--apply --manifest <missing>` now exits non-zero with a clear message instead of printing `removed=0 failed=0` and exiting 0, which would let automation treat a mistyped path as a successful sweep. Also replaces the O(n^2) nested-target dedup with an O(n log n) sort pass: each path is sorted under a trailing-'/' key so an ancestor sorts immediately before all its descendants (and never swallows a sibling like `buildstuff`), then a single scan drops anything under the last kept ancestor — the dry-run no longer spends minutes deduplicating a monorepo's thousands of candidates before it can show the confirmation plan. Regression tests cover containment rejection, arithmetic-injection refusal via a sentinel, and the missing-manifest fail-closed exit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016D1oCPX8LaUKnLUi3TXmih
|
Claude finished @kyle-sexton's task in 4m 3s —— View job PR Review — #1023 (HEAD
|
|
Claude finished @kyle-sexton's task in 3m 46s —— View job Security Review — PR #1023 (HEAD:
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9311037559
ℹ️ 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".
…rules The manifest containment guard stopped `..`/absolute escapes but still trusted that any in-repo path a manifest listed was a real cleanup target. A caller- supplied or concurrently-altered entry such as `caches\t1\tnotes` therefore passed the protection gate (an ordinary untracked dir is unprotected) and was removed while the run reported success. `clean_apply_manifest` now takes the tier's allowed classes and, per entry, rejects (fail closed, counted as a failure) any entry whose class the tier does not produce, and any path that is not a legitimate target for its class per the same candidate rules enumeration uses to find targets (explicit repo-root paths, a recognized dir-name leaf, or a file-glob leaf). clean-caches accepts only `caches`; clean-build accepts `build` and the caches it folds in. Regression tests cover a non-target untracked dir (`notes/`) and a wrong-tier entry — both rejected, preserved, non-zero exit. The rm-failure case now targets a valid explicit cache so it exercises the genuine Unremovable branch rather than target rejection. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016D1oCPX8LaUKnLUi3TXmih
|
Claude finished @kyle-sexton's task in 3m 42s —— View job PR Review — #1023 (HEAD
|
| Finding | Where fixed |
|---|---|
Arithmetic injection via bytes field |
9311037 — ^[0-9]+$ guard before $((...)) |
Path traversal via rel → arbitrary rm -rf |
9311037 — clean_manifest_rel_safe rejects ../absolute |
| Fail-open on missing manifest exits 0 | 9311037 — [[ -r "$MANIFEST_ARG" ]] guard in both entry scripts |
| Quadratic candidate dedup | 9311037 — O(n log n) sort-key pass |
| Cross-tier / non-target manifest abuse (Codex P1) | 76d3617 — clean_manifest_target_valid + allowed tier filter |
Correctness — Low (new in 76d3617)
base computed from the unnormalized argument, not from rel
clean_manifest_target_valid() {
local class="$1" rel="${2//\\//}" base="${2##*/}" e patBash evaluates all initialisers in a single local statement against the outer scope — base="${2##*/}" sees the raw $2, not the already-normalised rel. If a manifest entry carries Windows-style backslash separators (e.g. src\lib\bin), ##*/ finds no forward-slash to strip and base becomes the full string src\lib\bin. The CLEAN_BUILD_DIR_NAMES loop then fails to match bin, and the entry is rejected as "not a build target" — even though it is one.
The failure mode is a false rejection (safe but surprising), not a false acceptance — so this is not a security regression. However, it means legitimate backslash-path entries are silently refused, defeating the tier-validation's purpose on any environment where such paths could appear in the manifest. The fix is one additional local line:
clean_manifest_target_valid() {
local class="$1" e pat
local rel="${2//\\//}"
local base="${rel##*/}"Test-coverage — Nitpick (new in 76d3617)
Wrong-tier test captures rc but never asserts it
clean-caches.test.sh lines 124–131
out="$(run_r2 --apply --manifest "$TEST_TMPDIR/r2.wrongtier.manifest" 2>&1)"
rc=$?
assert_contains "wrong-tier entry rejected" "$out" "Rejected (wrong tier): bin"
assert_file_exists "wrong-tier target preserved" "$TEST_TMPDIR/r2/bin/x"
# ← missing: assert_exit "wrong-tier apply exits non-zero" 1 "$rc"Every other hardening test in the block (containment, injection, non-target, missing manifest) has a matching assert_exit. The wrong-tier case checks the stderr message and the preserved file but does not assert that the script exits 1. Adding assert_exit "wrong-tier apply exits non-zero" 1 "$rc" aligns it with the rest and pins the fail-closed contract.
Still outstanding from prior rounds
| Finding | Severity | Note |
|---|---|---|
| Staleness guard has no test | Correctness — Medium | No test covers: dry-run → .env added → apply → path preserved |
| Global accumulators not reset between calls | Architecture — Low | Safe today (one call per process); fragile to extend |
--manifest without value silently over-shifts |
Nitpick | shift 2 when $2 is absent; exit 2 guard would surface it early |
| PID-based fallback manifest path guessable on shared runners | Suggestion | Only reached on mktemp failure; abort instead of $$-suffix fallback |
Summary
The overall design is sound and all blocker-class findings are resolved. The new clean_manifest_target_valid function correctly closes the Codex P1 tier-confusion gap, with good integration tests for the happy path, non-target rejection, and missing manifest. The one new correctness issue (base computed from unnormalised $2) is a false-rejection bug — safe but wrong — and the fix is a trivial split of the local line. The missing assert_exit on the wrong-tier test is a one-liner. Neither is a security regression. The PR is otherwise merge-ready pending those two small fixes.
|
Claude finished @kyle-sexton's task in 4m 0s —— View job Security Review — PR #1023 (HEAD
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 76d361725c
ℹ️ 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".
…rwrite Two more hardenings of the untrusted --manifest surface: - Target type: manifest target validation now also checks the filesystem type each candidate rule emits (dir names / explicit dirs are found -type d, globs / explicit files -type f). A regular file merely named `bin` (or `__pycache__`) can no longer be removed by a crafted `build\t1\timportant/bin` — the dry-run could never have planned it. The existence check now precedes validation so a resumed, already-removed entry stays an idempotent no-op rather than a rejection. - Manifest overwrite: `--manifest <path>` is a caller-supplied input, so the dry-run build refuses to truncate an existing NON-manifest file (e.g. a mistyped `~/.config/app/settings`) and exits non-zero instead of silently erasing it; an absent path or an existing manifest-format file is still (re)written. Regression tests cover a file-as-dir-name target rejection and the refuse-to-overwrite guard (including that a real manifest is still rewritable). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016D1oCPX8LaUKnLUi3TXmih
|
Claude finished @kyle-sexton's task in 4m 32s —— View job PR Review — #1023 (HEAD
|
| Finding | Severity | Status |
|---|---|---|
| Staleness guard has no test | Correctness — Medium | Unresolved |
base computed from unnormalized $2 |
Correctness — Low | Unresolved |
Wrong-tier test missing assert_exit |
Nitpick | Unresolved |
| Global accumulators not reset between calls | Architecture — Low | Unresolved |
--manifest trailing arg silently over-shifts |
Nitpick | Unresolved |
allowed defaults to empty, skips tier check silently |
Suggestion | Unresolved |
PID-based fallback manifest path ($$) |
Suggestion | Unresolved |
Summary
Commit 137efb6 correctly closes both Codex findings: dir-name/explicit-dir targets now require -d and file-glob/explicit-file targets require -f, with the existence check appropriately hoisted before type validation so resume stays idempotent. The manifest overwrite guard (clean_manifest_writable_target) is a correct and well-tested defense. All prior CRITICAL and HIGH findings remain resolved.
The PR is substantively merge-ready. The two items worth landing before or shortly after merge are the staleness guard test (medium — dark code path covering the core protection re-check contract) and the base normalization one-liner (low — incorrect but safe false-rejection for backslash paths). The remaining items are optional improvements.
|
Claude finished @kyle-sexton's task in 4m 1s —— View job Security Review — PR #1023 (HEAD
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 137efb648a
ℹ️ 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".
…orever A trailing `--manifest` (e.g. `clean-caches.sh --apply --manifest`) hung: `$2` is empty so `shift 2` fails on the single remaining arg, and without `set -e` `$#` is unchanged, so the case loop reprocesses `--manifest` forever. Both entry scripts now require a value to follow `--manifest` and exit with the documented usage error (2) otherwise. Regression test guards with a `timeout` so a regression fails loudly instead of spinning. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016D1oCPX8LaUKnLUi3TXmih
|
Claude finished @kyle-sexton's task in 2m 31s —— View job PR Review — #1023 (HEAD
|
| Finding | Severity | Note |
|---|---|---|
| Staleness guard has no test | Correctness — Medium | No test covers: dry-run → .env added to target dir → apply → dir preserved. The guard code in clean_apply_manifest (re-calling clean_target_eligible) is dark and could regress silently. |
base computed from unnormalized $2 |
Correctness — Low | In clean_manifest_target_valid (line 449), base="${2##*/}" sees raw $2 before the rel="${2//\\//}" normalization. Backslash paths get base = src\lib\bin rather than bin, so dir-name lookups miss. False-rejection only (safe), but wrong. Fix: split the local into three lines. |
Wrong-tier test missing assert_exit |
Nitpick | clean-caches.test.sh lines 128–131 capture rc but never assert on it. Every other hardening test in the block has a matching assert_exit "… exits non-zero" 1 "$rc". |
| Global accumulators not reset between calls | Architecture — Low | CLEAN_CAND_ABS, CLEAN_PLANNED_COUNT, etc. initialized at source time only. Safe today (one call per process), fragile to extend. |
allowed defaults to empty; tier check silently skipped when omitted |
Suggestion | local … allowed="${3:-}" — an omitted third argument makes [[ -n "$allowed" ]] false and the tier filter never runs. All current call sites pass it correctly; the risk is a future call site that doesn't. |
| PID-based fallback manifest path guessable on shared runners | Suggestion | mktemp fallback uses $$ — predictable on Linux. Abort on mktemp failure instead. |
clean_manifest_writable_target follows symlinks; empty-file targets bypass content check |
Suggestion | [[ -f "$path" ]] follows symlinks; an attacker-supplied empty symlink target passes the -s guard and gets truncated. Add [[ -L "$path" ]] && return 1 before the -f check. |
Summary
1967662 cleanly closes the remaining nitpick-class CLI contract issue. All CRITICAL and HIGH security findings from prior rounds remain resolved. The PR is in good shape to merge.
The two items most worth landing before or shortly after merge are the staleness guard test (a dark code path covering the PR's core protection re-check guarantee) and the base normalization one-liner in clean_manifest_target_valid. The other items are optional improvements.
|
Claude finished @kyle-sexton's task in 2m 7s —— View job Security Review — PR #1023 (HEAD
|
|
Claude finished @kyle-sexton's task in 5m 41s —— View job PR Review — #1023 (HEAD
|
| Finding | Fixed in |
|---|---|
Arithmetic injection via bytes field |
9311037 (guard removed as moot in 770ca04) |
Path traversal via rel → arbitrary rm -rf |
9311037 |
| Fail-open on missing manifest | 9311037 |
| Quadratic candidate dedup | 9311037 |
| Cross-tier / non-target manifest entry | 76d3617 |
| Regular file accepted as dir-name target | 137efb6 |
--dry-run --manifest truncates non-manifest files |
137efb6 |
--manifest without value loops forever |
1967662 |
| Manifest entries inside pruned trees | 05f716a |
| No-trailing-newline / truncated record | 694eb56 |
| Uncreatable manifest path exits 0 | 8f0a905 |
| Symlinked targets accepted by type-check | 8f0a905 |
| Apply-loop fd drain (root-cause, silent skips) | 770ca04 |
| Stale / caller-injected byte field in arithmetic | 770ca04 |
| Explicit caches planned but rejected at apply | 770ca04 |
| Paths with tabs/newlines corrupt the manifest | 770ca04 |
Correctness — Medium (still outstanding)
Staleness guard has no manifest-flow integration test
clean_apply_manifest re-calls clean_target_eligible per entry — the protection re-check that is a first-class design goal (PR description: "a path that became protected since the dry-run is not removed"). The existing test at clean-build.test.sh lines 33–40 covers protection-detection in the full-dry-run path but not the manifest-consume path. No test exercises:
- Create a build dir, run
--dry-run --manifest P - Drop a
.envinside the dir - Run
--apply --manifest P - Assert
Skip (protected descendant):appears and the dir is untouched
If clean_target_eligible or clean_dir_has_protected_descendant is ever refactored, this code path could silently regress.
Correctness — Low (still outstanding, carry-forward from 76d3617)
base computed from unnormalized $2 in clean_manifest_target_valid
clean_manifest_target_valid() {
local class="$1" rel="${2//\\//}" abs="$3" base="${2##*/}" e patBash evaluates all initialisers in a single local declaration against the outer scope — base="${2##*/}" sees the raw $2 before the rel="${2//\\//}" normalization. A Windows-style path src\lib\bin gives base = src\lib\bin (no / to strip) rather than bin. The dir-name comparison loops then miss the legitimate match. The failure is a false rejection (safe — path preserved when it should be removed), but it means backslash manifests from any caller silently refuse to remove valid targets. The clean writer never produces backslash paths, so this only matters for the --apply --manifest surface with caller-supplied files on Windows. Fix: split into three local lines.
Nitpick — Wrong-tier test missing assert_exit (still outstanding)
clean-caches.test.sh lines 128–131
out="$(run_r2 --apply --manifest "$TEST_TMPDIR/r2.wrongtier.manifest" 2>&1)"
rc=$?
assert_contains "wrong-tier entry rejected" "$out" "Rejected (wrong tier): bin"
assert_file_exists "wrong-tier target preserved" "$TEST_TMPDIR/r2/bin/x"
# ← missing: assert_exit "wrong-tier apply exits non-zero" 1 "$rc"Every other hardening test in the block (escape, inject, non-target, pruned-tree, type-mismatch, missing-manifest, malformed-record, uncreatable-manifest, symlink, file-named-cache, inflated-bytes) has a matching assert_exit. The wrong-tier case is the only exception.
Nitpick — clean-build.test.sh missing trailing --manifest parity test (still outstanding)
clean-build.sh received the same [[ $# -lt 2 ]] guard as clean-caches.sh in commit 1967662 but clean-build.test.sh ends at line 108 with no matching regression test. clean-caches.test.sh has a timeout-wrapped assert_exit 2 test for this case.
Still outstanding — Suggestion-level items (carry-forward)
| Finding | Location | Note |
|---|---|---|
allowed defaults to empty; tier check silently skipped if argument omitted |
clean-common.sh line 550 |
All current call sites pass it correctly; a future call site that omits it bypasses tier isolation with no error signal |
| PID-based fallback manifest path guessable on shared runners | clean-common.sh line 650 |
mktemp fallback uses $$; abort on mktemp failure instead |
clean_manifest_writable_target follows symlinks for manifest destination |
clean-common.sh line 624 |
[[ -f "$path" ]] follows symlinks; an empty-target symlink passes the -s guard. [[ -L "$path" ]] && return 1 closes it |
Note — CHANGELOG not updated for 770ca04 fixes
The 0.5.0 CHANGELOG entry (CHANGELOG.md lines 6–45) describes the design as originally planned but doesn't reflect the correctness and security fixes applied during the review cycle. In particular:
- Apply-loop fd drain (silent multi-entry skip)
- Bytes re-measured from filesystem (not manifest)
- Explicit caches filtered at planning for dry-run/apply parity
These are user-visible behavior corrections — the first is a bug fix with observable effect (--apply --include-caches now correctly removes all targets, not just the first). Worth a brief Fixed bullet under 0.5.0 before shipping, though the changelog-parity gate does not enforce this at per-commit granularity.
Summary
770ca04 is a well-executed set of correctness fixes that address both open Codex P2 findings and discovers a previously undetected root-cause bug (fd drain by fsutil). The fd isolation pattern (<&3 / done 3<"$manifest") is correct, the bytes re-measurement is properly guarded, and the dry-run/apply parity check is now consistent across explicit and found paths. 4d8b060 is a clean style-only change.
The PR is merge-ready from a security standpoint — all CRITICAL and HIGH findings across the full review history are resolved. The one item most worth landing before merge is the staleness guard test (Correctness — Medium), which covers a first-class design guarantee with no test coverage in the manifest-consume path. The base normalization one-liner and two missing assert_exit assertions are small follow-ups.
|
Claude finished @kyle-sexton's task in 1m 51s —— View job Security Review — PR #1023 (HEAD
|
| Finding | Fixed in |
|---|---|
Arithmetic injection via bytes field |
9311037 / eliminated entirely in 770ca04 |
Path traversal via rel → arbitrary rm -rf |
9311037 |
--apply --manifest MISSING exits 0 |
9311037 |
| Quadratic candidate dedup | 9311037 |
| Cross-tier / non-target manifest entry | 76d3617 |
| Regular file accepted as dir-name target | 137efb6 |
--dry-run --manifest truncates non-manifest files |
137efb6 |
--manifest without value: infinite loop |
1967662 |
| Manifest entries inside pruned trees | 05f716a |
| No-trailing-newline final record dropped | 694eb56 |
| Truncated manifest record silently skipped | 694eb56 |
| Uncreatable manifest path exits 0 | 8f0a905 |
| Symlinked targets accepted by type-check | 8f0a905 |
| File/symlink with explicit-cache name planned at dry-run | 770ca04 |
| Stale byte field in summary arithmetic | 770ca04 |
fsutil draining stdin swallows manifest records |
770ca04 |
Remaining findings — SUGGESTION level (carry-forward, all unchanged)
SUGGESTION — allowed parameter defaults to empty; tier check silently disabled if $3 is ever omitted
Confidence: MEDIUM
clean_apply_manifest() {
local root="$1" manifest="$2" allowed="${3:-}"
...
if [[ -n "$allowed" && " $allowed " != *" $class "* ]]; thenWhen $3 is absent, allowed="" and [[ -n "$allowed" ]] short-circuits to false — the tier boundary is silently not enforced. All four current call sites pass the argument correctly ("build caches" / "caches"). No active vulnerability. The risk is a future call site omitting it and silently bypassing tier isolation. An explicit guard makes the omission loud:
[[ -n "$allowed" ]] || { printf 'clean_apply_manifest: allowed_classes is required\n' >&2; return 1; }SUGGESTION — PID-based fallback manifest path is guessable on shared CI runners
Confidence: MEDIUM
path="$(mktemp 2>/dev/null)" || path="${TMPDIR:-/tmp}/clean-manifest.$$"The $$-suffixed fallback (only reached when mktemp fails) uses a sequential and guessable PID on Linux. On a shared CI runner a co-tenant who predicts $$ and pre-creates /tmp/clean-manifest.<pid> as a symlink to an empty file causes the subsequent (: >"$path") subshell to truncate the symlink target. The primary mktemp path is safe. Aborting on mktemp failure eliminates the window:
path="$(mktemp 2>/dev/null)" || { printf 'clean_manifest_path: mktemp failed\n' >&2; return 1; }SUGGESTION — clean_manifest_writable_target follows symlinks; empty-file symlink target bypasses content guard
Confidence: LOW
[[ -e "$path" ]] || return 0
[[ -f "$path" ]] || return 1
[[ -s "$path" ]] || return 0 # empty → writable, no content check[[ -f "$path" ]] follows symlinks. A --manifest argument pointing at a symlink to an empty regular file passes the -s guard (zero-size → false → returns 0/writable), and the subsequent (: >"$path") truncates the symlink's target. Exploiting this requires an attacker who controls both the explicit --manifest argument and can create a symlink at that path — a narrow scenario. [[ -L "$path" ]] && return 1 as a second guard closes it cleanly:
[[ -e "$path" ]] || return 0
[[ -L "$path" ]] && return 1 # ← add this
[[ -f "$path" ]] || return 1
[[ -s "$path" ]] || return 0Summary
Commit 4d8b060 is a single-line comment change with no security impact. All CRITICAL and IMPORTANT findings across the full review history (16 findings across 11 prior rounds) remain resolved at HEAD. The three remaining items are all SUGGESTION-level and carry forward unchanged from the 770ca04 round — none is a blocker for merge.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4d8b060be2
ℹ️ 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".
`typos` flagged `applyable` and `mis-mapped` in the new comments; reword to
plain phrasing ("the plan can still be applied", "not mapped to a wrong path").
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016D1oCPX8LaUKnLUi3TXmih
|
Claude finished @kyle-sexton's task in 4m 6s —— View job PR Review — #1023 (HEAD
|
| Finding | Location | Note |
|---|---|---|
allowed defaults to empty; tier check silently skipped if $3 ever omitted |
clean-common.sh line 550 |
local … allowed="${3:-}" — a future call site omitting the argument bypasses tier isolation with no runtime signal. Guard: [[ -n "$allowed" ]] || { printf '…' >&2; return 1; } |
| PID-based fallback manifest path guessable on shared runners | clean-common.sh line 650 |
path="…/clean-manifest.$$" — abort on mktemp failure instead |
clean_manifest_writable_target follows symlinks |
clean-common.sh lines 623–625 |
[[ -f "$path" ]] follows links; add [[ -L "$path" ]] && return 1 before the -f check |
Summary of all findings across full review history
| Finding | Fixed in |
|---|---|
Arithmetic injection via bytes field |
9311037 / eliminated in 770ca04 |
Path traversal via rel → arbitrary rm -rf |
9311037 |
| Fail-open on missing manifest | 9311037 |
| Quadratic candidate dedup | 9311037 |
| Cross-tier / non-target manifest entry | 76d3617 |
| Regular file accepted as dir-name target | 137efb6 |
--dry-run --manifest truncates non-manifest files |
137efb6 |
--manifest without value loops forever |
1967662 |
| Manifest entries inside pruned trees | 05f716a |
| No-trailing-newline / truncated record | 694eb56 |
| Uncreatable manifest path exits 0 | 8f0a905 |
| Symlinked targets accepted by type-check | 8f0a905 |
| Apply-loop fd drain (fsutil drains stdin) | 770ca04 |
| Stale byte field in summary arithmetic | 770ca04 |
| Explicit caches planned but rejected at apply | 770ca04 |
| Paths with tabs/newlines corrupt the manifest | 770ca04 |
--include-caches ignored at --apply --manifest time |
unresolved |
| Staleness guard test | unresolved |
base from unnormalized $2 |
unresolved |
The PR is near merge-ready. The --include-caches correctness finding is the most important remaining item: it means the --include-caches flag has no effect when --apply --manifest is used, which contradicts the CLI contract and allows a manifest with caches entries to run even when the flag was not passed. The staleness guard test and the base one-liner are the next priorities; the nitpicks and suggestions are optional.
|
Claude finished @kyle-sexton's task in 2m 12s —— View job Security Review — PR #1023 (HEAD
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9b14290de1
ℹ️ 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".
The `..`/absolute containment guard and the final-component reparse check did not cover a symlinked ANCESTOR: a manifest naming `link/__pycache__`, where `link` points outside the repo, passed every check and `rm -rf` followed the ancestor symlink to delete the external directory while reporting success. Enumeration uses `find -type d`, which never descends a symlinked dir, so such a path is never one the dry-run emitted. Apply now walks every component of `root/rel` and rejects the entry (fail closed) if any ancestor or the target is a symlink/reparse point — junction-aware via the existing fsutil check, so it also catches Windows reparse-point ancestors that `realpath`/`-L` miss. Regression test covers a symlinked ancestor pointing outside the repo (skips where the FS cannot create a symlink). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016D1oCPX8LaUKnLUi3TXmih
|
Claude finished @kyle-sexton's task in 4m 25s —— View job PR Review — #1023 (HEAD
|
| Finding | Severity | Note |
|---|---|---|
| Staleness guard has no integration test | Correctness — Medium | No test: dry-run → add .env → apply → Skip (protected descendant): appears and dir preserved. The protection re-check code in clean_apply_manifest (line 620) is dark. |
base computed from unnormalized $2 |
Correctness — Low | local … base="${2##*/}" sees raw $2 before rel="${2//\\//}" normalization in clean_manifest_target_valid (line 522). Backslash paths get wrong base; false rejection only (safe but wrong). |
Wrong-tier test missing assert_exit |
Nitpick | clean-caches.test.sh lines 128–131 capture rc but never assert on it. Every other hardening test has a matching assert_exit "… exits non-zero" 1 "$rc". |
clean-build.test.sh trailing --manifest parity test |
Nitpick | clean-build.sh got the same [[ $# -lt 2 ]] guard in 1967662 but clean-build.test.sh has no matching regression test. |
allowed defaults to empty; tier check silently skipped |
Suggestion | local … allowed="${3:-}" — omitting $3 bypasses tier isolation with no error signal. |
| PID-based fallback manifest path guessable | Suggestion | mktemp fallback at line 676 uses $$; abort on mktemp failure instead. |
clean_manifest_writable_target follows symlinks |
Suggestion | [[ -f "$path" ]] follows links; add [[ -L "$path" ]] && return 1 before the -f check. |
Summary
The clean_path_has_reparse_ancestor implementation is correct and the new test for the ancestor case is well-constructed. The one issue is that the function also intercepts direct-target symlinks (e.g. .turbo), changing the rejection message for test 11 from "Rejected (not a caches target)" to "Rejected (symlinked ancestor)" — breaking that test on Linux CI. The fix is a one-line assertion update. All CRITICAL and HIGH security findings across the full review history remain resolved; the PR is otherwise merge-ready pending the test fix.
|
Claude finished @kyle-sexton's task in 2m 51s —— View job Security Review — PR #1023 (HEAD
|
| Finding | Fixed in |
|---|---|
Arithmetic injection via bytes field |
9311037 / eliminated in 770ca04 |
Path traversal via rel → arbitrary rm -rf |
9311037 |
| Fail-open on missing manifest exits 0 | 9311037 |
| Quadratic candidate dedup | 9311037 |
| Cross-tier / non-target manifest entry | 76d3617 |
| Regular file accepted as dir-name target | 137efb6 |
--dry-run --manifest truncates non-manifest files |
137efb6 |
--manifest without value loops forever |
1967662 |
| Manifest entries inside pruned trees | 05f716a |
| No-trailing-newline / truncated record | 694eb56 |
| Uncreatable manifest path exits 0 | 8f0a905 |
| Symlinked final-component target accepted | 8f0a905 |
Apply-loop fd drain (fsutil drains stdin) |
770ca04 |
| Stale byte field in summary arithmetic | 770ca04 |
| Explicit caches planned but rejected at apply | 770ca04 |
| Paths with tabs/newlines corrupt the manifest | 770ca04 |
Symlinked ancestor lets rm -rf exit the repo |
c84086d ✓ |
Remaining findings — SUGGESTION level (carry-forward, all unchanged)
SUGGESTION — clean_manifest_writable_target follows symlinks; empty-file symlink target bypasses content check
Confidence: LOW
[[ -e "$path" ]] || return 0
[[ -f "$path" ]] || return 1
[[ -s "$path" ]] || return 0 # empty → writable, no content check[[ -f "$path" ]] follows symlinks. A --manifest argument pointing at a symlink to an empty regular file passes the -s guard (zero-size → false → returns 0/writable), and the subsequent (: >"$path") truncates the symlink's target. Exploiting this requires an attacker who controls both the explicit --manifest argument and can create a symlink at that path — a narrow scenario. [[ -L "$path" ]] && return 1 as a second guard closes it:
[[ -e "$path" ]] || return 0
[[ -L "$path" ]] && return 1 # ← add this
[[ -f "$path" ]] || return 1
[[ -s "$path" ]] || return 0SUGGESTION — PID-based fallback manifest path is guessable on shared CI runners
Confidence: MEDIUM
path="$(mktemp 2>/dev/null)" || path="${TMPDIR:-/tmp}/clean-manifest.$$"The $$-suffixed fallback (only reached when mktemp fails) uses a sequential, guessable PID on Linux. On a shared CI runner, a co-tenant who pre-creates /tmp/clean-manifest.<pid> as a symlink to an empty file causes the subsequent (: >"$path") to truncate the symlink target. Aborting on mktemp failure eliminates this window:
path="$(mktemp 2>/dev/null)" || { printf 'clean_manifest_path: mktemp failed\n' >&2; return 1; }SUGGESTION — allowed parameter defaults to empty; tier check silently disabled if $3 is ever omitted
Confidence: MEDIUM
if [[ -n "$allowed" && " $allowed " != *" $class "* ]]; thenWhen $3 is absent, allowed="" and [[ -n "$allowed" ]] short-circuits to false — the tier boundary is silently not enforced. All four current call sites pass the argument correctly ("build caches" / "caches"). The risk is a future call site omitting it with no runtime signal. An explicit guard converts the omission to a loud failure:
[[ -n "$allowed" ]] || { printf 'clean_apply_manifest: allowed_classes is required\n' >&2; return 1; }Summary
Commit c84086d correctly closes the symlinked-ancestor gap. The clean_path_has_reparse_ancestor function is correctly implemented — it walks every path component incrementally from root through to the final segment, checking each via the platform-aware clean_path_is_reparse_point (POSIX [[ -L ]] + Windows fsutil). Backslash normalisation, empty-segment skipping, and the </dev/null guard on fsutil are all correct. The placement in clean_apply_manifest (after the existence check, before target validation) is correct. No new security vulnerabilities were introduced.
All CRITICAL and IMPORTANT findings across 13 prior review rounds are resolved at HEAD. The three remaining items are all SUGGESTION-level — none is a blocker for merge.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c84086d3a9
ℹ️ 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".
The new symlinked-ancestor walk also catches a symlinked final target (it walks every component including the leaf), which fires before the type check — so a symlinked `.turbo` is now rejected as a symlinked path, not "not a caches target". Rename the marker to `Rejected (symlinked path)` (accurate for both a symlinked ancestor and a symlinked target) and align the two symlink regression tests, which now run on Linux CI. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016D1oCPX8LaUKnLUi3TXmih
|
Claude finished @kyle-sexton's task in 3m 30s —— View job PR Review — #1023 (HEAD
|
| Finding | Fixed in |
|---|---|
Arithmetic injection via bytes field |
9311037 / eliminated in 770ca04 |
Path traversal via rel → arbitrary rm -rf |
9311037 |
| Fail-open on missing manifest exits 0 | 9311037 |
| Quadratic candidate dedup | 9311037 |
| Cross-tier / non-target manifest entry | 76d3617 |
| Regular file accepted as dir-name target | 137efb6 |
--dry-run --manifest truncates non-manifest files |
137efb6 |
--manifest without value loops forever |
1967662 |
| Manifest entries inside pruned trees | 05f716a |
| No-trailing-newline / truncated record | 694eb56 |
| Uncreatable manifest path exits 0 | 8f0a905 |
| Symlinked final-target accepted by type-check | 8f0a905 |
Apply-loop fd drain (fsutil drains stdin) |
770ca04 |
| Stale byte field in summary arithmetic | 770ca04 |
| Explicit caches planned but rejected at apply | 770ca04 |
| Paths with tabs/newlines corrupt the manifest | 770ca04 |
Symlinked ancestor lets rm -rf exit the repo |
c84086d |
| Inconsistent symlinked-path rejection message / broken test | bd20952 ✓ |
Correctness — Medium (still outstanding)
Staleness guard has no integration test
clean_apply_manifest re-calls clean_target_eligible per entry — the protection re-check that is a first-class design goal (PR description: "a path that became protected since the dry-run is not removed"). The existing test at clean-build.test.sh lines 35–40 covers protection detection in the full --apply path but not through the manifest-consume path. No test exercises:
- Create a
dist/dir, run--dry-run --manifest P - Drop a
.envinsidedist/ - Run
--apply --manifest P - Assert
Skip (protected descendant):appears anddist/is untouched
If clean_target_eligible or clean_dir_has_protected_descendant is ever refactored, this code path could silently regress.
Correctness — Low (still outstanding, carry-forward from 76d3617)
base computed from unnormalized $2 in clean_manifest_target_valid
clean_manifest_target_valid() {
local class="$1" rel="${2//\\//}" abs="$3" base="${2##*/}" e patBash evaluates all initialisers in a single local declaration against the outer scope — base="${2##*/}" sees raw $2 before the rel="${2//\\//}" normalization. A Windows-style path src\lib\bin gives base = src\lib\bin (no / to strip) rather than bin. The dir-name comparison loops against CLEAN_BUILD_DIR_NAMES entries like bin then miss, and the entry is rejected as "not a build target" — a false rejection. The failure is safe (path is preserved, not incorrectly removed), and the clean writer never produces backslash paths. One-line fix:
clean_manifest_target_valid() {
local class="$1" abs="$3" e pat
local rel="${2//\\//}"
local base="${rel##*/}"Nitpick — Wrong-tier test missing assert_exit (still outstanding)
clean-caches.test.sh lines 128–131
out="$(run_r2 --apply --manifest "$TEST_TMPDIR/r2.wrongtier.manifest" 2>&1)"
rc=$?
assert_contains "wrong-tier entry rejected" "$out" "Rejected (wrong tier): bin"
assert_file_exists "wrong-tier target preserved" "$TEST_TMPDIR/r2/bin/x"
# ← missing: assert_exit "wrong-tier apply exits non-zero" 1 "$rc"Every other hardening test — escape, inject, non-target, pruned-tree, type-mismatch, missing-manifest, malformed-record, uncreatable-manifest, symlink (×2), file-named-cache, inflated-bytes — has a matching assert_exit. The wrong-tier case is the only exception. One line to add.
Nitpick — clean-build.test.sh missing trailing --manifest parity test (still outstanding)
clean-build.sh received the same [[ $# -lt 2 ]] guard as clean-caches.sh in commit 1967662, but clean-build.test.sh ends at line 108 with no matching regression test. clean-caches.test.sh test 9 covers this with a timeout-wrapped invocation, assert_exit 2, and assert_contains "--manifest requires a value".
Suggestion (still outstanding)
allowed defaults to empty; tier check silently skipped if argument ever omitted
clean-common.sh line 571 / line 590
clean_apply_manifest() {
local root="$1" manifest="$2" allowed="${3:-}"
...
if [[ -n "$allowed" && " $allowed " != *" $class "* ]]; thenWhen $3 is absent, allowed="" and [[ -n "$allowed" ]] short-circuits false — the tier boundary is silently not enforced. All four current call sites pass the argument correctly. The risk is a future call site omitting it with no runtime signal. An explicit guard makes the omission loud:
[[ -n "$allowed" ]] || { printf 'clean_apply_manifest: allowed_classes is required\n' >&2; return 1; }PID-based fallback manifest path guessable on shared CI runners
path="$(mktemp 2>/dev/null)" || path="${TMPDIR:-/tmp}/clean-manifest.$$"The $$-suffixed fallback (only reached when mktemp fails) uses a sequential, guessable PID on Linux. Aborting on mktemp failure eliminates the window: || { printf 'clean_manifest_path: mktemp failed\n' >&2; return 1; }.
clean_manifest_writable_target follows symlinks; empty-file symlink target bypasses content check
[[ -f "$path" ]] follows symlinks. A --manifest pointing at a symlink to an empty file passes the -s guard and (: >"$path") truncates the symlink target. Add [[ -L "$path" ]] && return 1 before the -f check.
Summary
bd20952 takes the correct approach to the test assertion issue — renaming the rejection message to Rejected (symlinked path) rather than just patching the test, giving both cases (symlinked ancestor, direct symlink target) a single, unified, accurate marker. The commit is clean; no new issues introduced.
All CRITICAL and HIGH security findings across 14 review rounds are resolved at HEAD. The PR is merge-ready from a security standpoint. The one item most worth landing before merge is the staleness guard test (Correctness — Medium) — it covers a first-class design guarantee (protection re-check in the manifest-consume path) with no test coverage. The base normalization one-liner and the two missing assert_exit / assert_exit 2 assertions are small follow-ups. The three Suggestion items are optional defence-in-depth.
|
Claude finished @kyle-sexton's task in 1m 49s —— View job Security Review — PR #1023 (HEAD
|
| Finding | Fixed in | Status |
|---|---|---|
Arithmetic injection via bytes field |
9311037 / eliminated in 770ca04 |
✅ bytes is never used in arithmetic; du -sk re-measures at removal time (line 629) with a ^[0-9]+$ guard |
Path traversal via rel → arbitrary rm -rf |
9311037 |
✅ clean_manifest_rel_safe() still rejects .. components and absolute paths |
--apply --manifest MISSING exits 0 |
9311037 |
✅ Both entry scripts guard [[ -r ]] before consuming |
| Quadratic candidate dedup | 9311037 |
✅ O(n log n) sort-key pass unchanged |
| Cross-tier / non-target manifest entry | 76d3617 |
✅ clean_manifest_target_valid + allowed tier filter unchanged |
| Regular file accepted as dir-name target | 137efb6 |
✅ clean_is_plain_dir/clean_is_plain_file checks unchanged |
--dry-run --manifest truncates non-manifest files |
137efb6 |
✅ clean_manifest_writable_target() unchanged |
--manifest without value loops forever |
1967662 |
✅ [[ $# -lt 2 ]] guard unchanged |
| Manifest entries inside pruned trees | 05f716a |
✅ clean_path_has_pruned_segment() unchanged |
| No-trailing-newline / truncated record | 694eb56 |
✅ ` |
| Uncreatable manifest path exits 0 | 8f0a905 |
✅ (: >"$path") creation check unchanged |
| Symlinked targets accepted by type-check | 8f0a905 |
✅ Superseded — clean_path_has_reparse_ancestor now catches all symlinked paths (ancestors and leaf targets) before type-check runs |
Apply-loop fd drain (fsutil drains stdin) |
770ca04 |
✅ Manifest on fd 3 + </dev/null on fsutil unchanged |
| Stale byte field in summary arithmetic | 770ca04 |
✅ du -sk re-measurement unchanged |
| Explicit caches planned but rejected at apply | 770ca04 |
✅ Type-check parity at planning time unchanged |
| Paths with tabs/newlines corrupt the manifest | 770ca04 |
✅ Unencodable-path guard unchanged |
Symlinked ancestor/path lets rm -rf exit the repo |
c84086d + bd20952 |
✅ clean_path_has_reparse_ancestor() catches all components including the leaf; rejection message correctly renamed to Rejected (symlinked path): |
No new security issues were introduced by bd20952.
Remaining findings — SUGGESTION level (carry-forward, all unchanged)
SUGGESTION — allowed parameter defaults to empty; tier check silently disabled if $3 ever omitted
Confidence: MEDIUM
clean_apply_manifest() {
local root="$1" manifest="$2" allowed="${3:-}"
...
if [[ -n "$allowed" && " $allowed " != *" $class "* ]]; thenWhen $3 is absent, allowed="" and [[ -n "$allowed" ]] short-circuits to false — the tier boundary is silently not enforced. All four current call sites pass the argument correctly ("build caches" / "caches"). No active vulnerability. The risk is a future call site omitting it with no runtime signal. A guard makes the omission loud:
[[ -n "$allowed" ]] || { printf 'clean_apply_manifest: allowed_classes is required\n' >&2; return 1; }SUGGESTION — PID-based fallback manifest path guessable on shared CI runners
Confidence: MEDIUM
path="$(mktemp 2>/dev/null)" || path="${TMPDIR:-/tmp}/clean-manifest.$$"The $$-suffixed fallback (only reached when mktemp fails) uses a sequential, guessable PID on Linux. On a shared CI runner, a co-tenant who pre-creates /tmp/clean-manifest.<pid> as a symlink to an empty file causes the subsequent (: >"$path") to truncate the symlink target. Aborting on mktemp failure eliminates the window:
path="$(mktemp 2>/dev/null)" || { printf 'clean_manifest_path: mktemp failed\n' >&2; return 1; }SUGGESTION — clean_manifest_writable_target follows symlinks; empty-file symlink target bypasses content check
Confidence: LOW
[[ -e "$path" ]] || return 0
[[ -f "$path" ]] || return 1
[[ -s "$path" ]] || return 0 # empty → writable, no content check[[ -f "$path" ]] follows symlinks. A --manifest argument pointing at a symlink to an empty regular file passes the -s guard (zero-size → false → returns 0/writable), and the subsequent (: >"$path") truncates the symlink's target. Exploiting this requires an attacker who controls both the explicit --manifest argument and can create a symlink at that path — a narrow scenario. [[ -L "$path" ]] && return 1 as a second guard closes it:
[[ -e "$path" ]] || return 0
[[ -L "$path" ]] && return 1 # ← add this
[[ -f "$path" ]] || return 1
[[ -s "$path" ]] || return 0Summary
Commit bd20952 is a message-rename-only commit with no security impact. The rename from Rejected (symlinked ancestor): to Rejected (symlinked path): is semantically correct — clean_path_has_reparse_ancestor walks all path components including the final target, so "ancestor" was always slightly inaccurate for single-component paths like .turbo. All CRITICAL and IMPORTANT findings across 13 prior review rounds remain resolved at HEAD. The three remaining items are all SUGGESTION-level; none is a blocker for merge.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: bd20952ba3
ℹ️ 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".
A monorepo with thousands of surviving candidates would exceed ARG_MAX in the
single `du -sk "${surv_abs[@]}"` sizing exec; with stderr discarded, du failed to
run and every entry fell back to 0, so the dry-run manifest and `Summary:
planned=N bytes=K` reported 0 reclaimable bytes for a real cleanup. Size via a
NUL-delimited `xargs -0 du -sk` so the paths are chunked across as many execs as
needed. Survivors never contain a tab/newline (skipped at classification), so the
tab-split parse stays unambiguous.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016D1oCPX8LaUKnLUi3TXmih
|
Claude finished @kyle-sexton's task in 3m 11s —— View job PR Review — #1023 (HEAD
|
| Finding | Fixed in |
|---|---|
Arithmetic injection via bytes field |
9311037 / eliminated in 770ca04 |
Path traversal via rel → arbitrary rm -rf |
9311037 |
| Fail-open on missing manifest exits 0 | 9311037 |
| Quadratic candidate dedup | 9311037 |
| Cross-tier / non-target manifest entry | 76d3617 |
| Regular file accepted as dir-name target | 137efb6 |
--dry-run --manifest truncates non-manifest files |
137efb6 |
--manifest without value loops forever |
1967662 |
| Manifest entries inside pruned trees | 05f716a |
| No-trailing-newline / truncated record | 694eb56 |
| Uncreatable manifest path exits 0 | 8f0a905 |
| Symlinked targets accepted by type-check | 8f0a905 |
Apply-loop fd drain (fsutil drains stdin) |
770ca04 |
| Stale byte field in summary arithmetic | 770ca04 |
| Explicit caches planned but rejected at apply | 770ca04 |
| Paths with tabs/newlines corrupt the manifest | 770ca04 |
Symlinked ancestor lets rm -rf exit the repo |
c84086d |
| Inconsistent symlinked-path rejection message | bd20952 |
| ARG_MAX overflow → silent 0-byte sizing in dry-run | 7624dff ✓ |
Correctness — Medium (still outstanding)
Staleness guard has no integration test
clean_apply_manifest re-calls clean_target_eligible per entry — the protection re-check that is a first-class design goal (PR description: "a path that became protected since the dry-run is not removed"). clean-build.test.sh lines 35–40 cover protection detection in the standalone --apply path but not through the manifest-consume path. No test exercises:
- Create a
dist/dir, run--dry-run --manifest P - Drop a
.envinsidedist/ - Run
--apply --manifest P - Assert
Skip (protected descendant):appears anddist/is untouched
A future refactor of clean_target_eligible or clean_dir_has_protected_descendant could silently regress this guarantee without any test catching it. Fix this →
Correctness — Low (still outstanding, carry-forward from 76d3617)
base computed from unnormalized $2 in clean_manifest_target_valid
clean_manifest_target_valid() {
local class="$1" rel="${2//\\//}" abs="$3" base="${2##*/}" e patBash evaluates all initialisers in a single local declaration against the outer scope — base="${2##*/}" sees the raw $2 before the rel="${2//\\//}" normalization. A Windows-style path src\lib\bin gives base = src\lib\bin (no / to strip) rather than bin. The dir-name loops comparing [[ "$base" == "$e" ]] then miss and the entry is rejected as "not a build target" — a false rejection (safe, not a security issue). The clean writer never produces backslash paths, so this only affects callers on Windows supplying --apply --manifest with a backslash manifest. One-line split fixes it:
clean_manifest_target_valid() {
local class="$1" abs="$3" e pat
local rel="${2//\\//}"
local base="${rel##*/}"Nitpick — Wrong-tier test missing assert_exit (still outstanding)
clean-caches.test.sh lines 128–131
out="$(run_r2 --apply --manifest "$TEST_TMPDIR/r2.wrongtier.manifest" 2>&1)"
rc=$?
assert_contains "wrong-tier entry rejected" "$out" "Rejected (wrong tier): bin"
assert_file_exists "wrong-tier target preserved" "$TEST_TMPDIR/r2/bin/x"
# ← missing: assert_exit "wrong-tier apply exits non-zero" 1 "$rc"Every other hardening test — escape (1), inject (2), non-target (3a), pruned-tree (3b), type-mismatch (4), no-trailing-newline (5), malformed-record (6), missing-manifest (7), refuse-overwrite (8), trailing-manifest (9), uncreatable-manifest (10), symlink (11), symlinked-ancestor — has a matching assert_exit. The wrong-tier case is the only exception; one line to add. Fix this →
Nitpick — clean-build.test.sh missing trailing --manifest parity test (still outstanding)
clean-build.sh received the same [[ $# -lt 2 ]] guard as clean-caches.sh in commit 1967662, but clean-build.test.sh ends at line 108 with no matching regression test. clean-caches.test.sh test 9 covers this with a timeout-wrapped invocation, assert_exit 2, and assert_contains "--manifest requires a value". A parity test pins the contract for both scripts. Fix this →
Still outstanding — Suggestion-level items (carry-forward)
| Finding | Location | Note |
|---|---|---|
allowed defaults to empty; tier check silently skipped if $3 omitted |
clean-common.sh line 576 |
local … allowed="${3:-}" — all current call sites pass it correctly; a future call site omitting it bypasses tier isolation with no error signal |
| PID-based fallback manifest path guessable on shared runners | clean-common.sh line 681 |
mktemp fallback uses $$; abort on mktemp failure instead |
clean_manifest_writable_target follows symlinks; empty-file symlink target bypasses content check |
clean-common.sh lines 654–656 |
[[ -f "$path" ]] follows symlinks; add [[ -L "$path" ]] && return 1 before the -f check |
Summary
Commit 7624dff correctly closes the ARG_MAX sizing gap: printf '%s\0' … | xargs -0 du -sk chunks large candidate lists across multiple du execs transparently, and the tab-split parse stays unambiguous because the unencodable-path guard (added in 770ca04) already filters tab/newline paths at classification. No new security issues were introduced.
All CRITICAL and HIGH findings across every prior review round are resolved at HEAD. The PR is merge-ready from a security standpoint. The staleness guard test (Correctness — Medium) remains the one item most worth landing before or shortly after merge — it covers the core protection re-check guarantee in the manifest-consume path, which is currently dark. The base normalization one-liner and the two missing test assertions are small clean-ups. The three Suggestion items are optional defence-in-depth.
|
Claude finished @kyle-sexton's task in 2m 14s —— View job Security Review — PR #1023 (HEAD
|
| Finding | Fixed in |
|---|---|
Arithmetic injection via bytes field |
9311037 / eliminated in 770ca04 |
Path traversal via rel → arbitrary rm -rf |
9311037 |
--apply --manifest MISSING exits 0 |
9311037 |
| Quadratic candidate dedup | 9311037 |
| Cross-tier / non-target manifest entry | 76d3617 |
| Regular file accepted as dir-name target | 137efb6 |
--dry-run --manifest truncates non-manifest files |
137efb6 |
--manifest without value: infinite loop |
1967662 |
| Manifest entries inside pruned trees | 05f716a |
| No-trailing-newline / truncated record | 694eb56 |
| Uncreatable manifest path exits 0 | 8f0a905 |
| Symlinked final-component target accepted | 8f0a905 |
Apply-loop fd drain (fsutil drains stdin) |
770ca04 |
| Stale byte field in summary arithmetic | 770ca04 |
| Explicit caches planned but rejected at apply | 770ca04 |
| Paths with tabs/newlines corrupt the manifest | 770ca04 |
Symlinked ancestor lets rm -rf exit the repo |
c84086d |
| Inconsistent symlinked-path rejection message | bd20952 |
| Dry-run sizing silently zeros at E2BIG | 7624dff ✓ |
Remaining findings — SUGGESTION level (carry-forward, all unchanged)
SUGGESTION — allowed parameter defaults to empty; tier check silently disabled if $3 is ever omitted
Confidence: MEDIUM
clean_apply_manifest() {
local root="$1" manifest="$2" allowed="${3:-}"
...
if [[ -n "$allowed" && " $allowed " != *" $class "* ]]; thenWhen $3 is absent, allowed="" and [[ -n "$allowed" ]] short-circuits to false — the tier boundary is silently not enforced. All four current call sites pass the argument correctly ("build caches" / "caches"). No active vulnerability; the risk is a future call site omitting it without a runtime signal.
SUGGESTION — PID-based fallback manifest path is guessable on shared CI runners
Confidence: MEDIUM
path="$(mktemp 2>/dev/null)" || path="${TMPDIR:-/tmp}/clean-manifest.$$"The $$-suffixed fallback (only reached when mktemp fails) uses a sequential, guessable PID on Linux. On a shared CI runner, a co-tenant who pre-creates /tmp/clean-manifest.<pid> as a symlink to an empty file causes the subsequent (: >"$path") subshell to truncate the symlink target. Aborting on mktemp failure eliminates the window: || { printf 'clean_manifest_path: mktemp failed\n' >&2; return 1; }.
SUGGESTION — clean_manifest_writable_target follows symlinks; empty-file symlink target bypasses content check
Confidence: LOW
[[ -e "$path" ]] || return 0
[[ -f "$path" ]] || return 1
[[ -s "$path" ]] || return 0 # empty → writable, no content check[[ -f "$path" ]] follows symlinks. A --manifest argument pointing at a symlink to an empty regular file passes the -s guard and (: >"$path") truncates the symlink's target. Exploiting this requires an attacker who controls both the explicit --manifest argument and can create a symlink at that path — a very narrow scenario. [[ -L "$path" ]] && return 1 as a second guard closes it.
Summary
Commit 7624dff is a correct and safe fix: printf '%s\0' … | xargs -0 du -sk is the idiomatic POSIX-safe way to chunk large argument lists under ARG_MAX, introduces no injection surfaces, and preserves the size_of["$path"] lookup semantics unchanged. No new security issues were introduced. All CRITICAL and IMPORTANT findings across 14 review rounds are resolved at HEAD. The three remaining items are all SUGGESTION-level and none is a blocker for merge.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7624dfff20
ℹ️ 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".
| # `|| [[ -n … ]]` processes a final record with no trailing newline (common in | ||
| # caller-written files) instead of dropping it — dropping it would report a | ||
| # cleanup as done while leaving it undone. | ||
| while IFS=$'\t' read -r class bytes rel <&3 || [[ -n "$class$bytes$rel" ]]; do |
There was a problem hiding this comment.
Reject manifest records with extra fields
When a caller-supplied or concurrently altered manifest line has a fourth tab-delimited field, this read stores the rest of the line in rel instead of treating the record as malformed. For example, caches\t1\t.pytest_cache\textra makes apply check for a path containing a tab, skip it as already gone, and exit with failed=0 while leaving .pytest_cache in place; if such a tab-containing path exists, it can also remove a path the dry-run intentionally refuses to encode. Validate that records have exactly three fields, or reject tab characters in rel, before the idempotent missing-path skip.
Useful? React with 👍 / 👎.
Fold PR #1023's now-merged single-repo manifest work (squashed to main) into the fleet-batch branch, and land the CI fixes: shebang exec bits on the new scripts, corrected shellcheck source directives, and generic example paths in the batch-common test.
Bring the PR up to date with main (now containing #1023). Conflicts resolved: plugin.json keeps the 0.6.0 bump over main's 0.5.0; clean-common.sh takes main's finalized version; cleanup-paths.sh, CHANGELOG.md, action-router.md keep this branch's Batch-D additions layered over main's content. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016D1oCPX8LaUKnLUi3TXmih
…rs (#1064) ## Summary Adds fleet (batch) mode for the clean skill's selective tiers — `caches` / `build` / `git` / `all` — so a multi-repo sweep no longer has to be hand-rolled (which the auto-mode classifier blocks). This is the selective-tier sibling of the existing `tree-batch`. A new `clean-batch.sh --tier <caches|build|git|all>` orchestrator runs the single-repo tiers across a repo set behind ONE confirmation gate. It runs no removal itself — every per-repo action delegates to the unchanged single-repo child (`clean-caches.sh`, `clean-build.sh`, `git-prune.sh`), so every child gate (protection classes, submodule/reparse guards, the dry-run manifest + re-stat staleness guard) is reused verbatim. New action spellings: `caches-batch` / `build-batch` / `git-batch` / `all-batch` (plus `*-fleet` aliases), resolved by `resolve-clean-action.sh`. ## Design - **One `--tier` orchestrator, not four scripts.** The batch-plan format, the single batch-wide gate, and the apply-from-plan logic are one cohesive contract; the tier is a data difference (which child + manifest-mode vs gitdir-mode), not a structural one. The resolver maps the four spellings to `clean-batch.sh --tier X`. - **The batch plan IS the gated set.** `--dry-run` writes a plan (REPO lines → per-repo child manifest; GITDIR lines → unique shared object stores) and prints `BatchPlan: <path>` + aggregate `Summary: repos=N planned=P bytes=K`. `--apply --batch-plan <path>` acts on that plan ONLY and errors without it. This is the fleet-level analogue of the child's per-repo manifest staleness guard: a repo that vanished after the dry-run applies idempotently (paths already gone); a repo that appeared is not in the plan, so it is never touched. - **Central path normalization** (`lib/batch-common.sh`): `ghq list -p` backslash paths → the git-friendly `D:/repos/...` forward-slash form once (backslashes break `xargs` and `[[ -d ]]`; `git check-ignore` rejects MSYS `/d/…` forms). - **Shared-object-store dedup:** the `git` tier groups repos by unique `git rev-parse --git-common-dir` and prunes each store once (from a representative worktree cwd), not once per linked worktree. - **Shared plumbing in `lib/batch-common.sh`.** `tree-batch` predates this module and keeps its own inline copy of read-lines / resolve-dedup / emit; migrating it onto `batch-common.sh` is a deliberate fast-follow (kept out of scope to keep this diff off #1023's churn and tree-batch's large test suite). Recorded as a decision (noted in the lib header), not silence. ## Spec deviations (recorded) - **Batch `git` = prune / gc / remote-prune only.** Branch audit/deletion is excluded because interactive per-branch deletion can't sit behind one fleet-wide gate. Therefore **`all-batch` = build + git-prune, no branch audit** — matching the single-repo `all` (build + git, no tree). - **`tree` is not batched here** — the destructive tier has its own batch form (`tree-batch`) with a dirty guard. ## Independent review An independent reviewer (fresh context) audited the apply path; fixes landed here: - **Fail-closed aggregate counter** — a per-repo child that exits non-zero without a parseable `Summary` (e.g. a repo that lost its `.git` mid-sweep) now counts as a failure, so the batch exits 1 instead of silently reporting `failed=0` / exit 0. - **Fleet-alias safety routing** — a selective tier token co-occurring with a fleet indicator (`clean caches across all repos`, `caches fleet`, `prune git across the fleet`) routes to the non-destructive `<tier>-batch`, never the destructive `tree-batch`; the ambiguous bare "fleet" trigger was dropped from the skill description. - **Batch-plan write guard** — the dry-run creates the plan directory and verifies the plan is writable, failing loudly instead of printing `BatchPlan` / `Summary` and exiting 0 with no plan. - Apply summary gates the `gitdirs=` field to the git/all tiers; the gitdir first-seen-representative limitation is documented. ## Testing - New `clean-batch.test.sh` and `lib/batch-common.test.sh`, TDD-first; resolver gains routing cases including the fleet-safety ones. All repo-hygiene `*.test.sh` green; `shellcheck --rcfile .shellcheckrc`, `shfmt -i 2`, and markdownlint clean. Re-run against this PR's rebased (hardened) single-repo children. - Verified with real multi-repo **dry-runs** (never `--apply`) over a local ghq fleet: caches-batch over 3 repos; git-batch over a main clone + its linked worktree correctly reports `gitdirs=1` (shared object store deduped); `node_modules` present in a repo is correctly preserved (0 planned). ## Related - #1003 — umbrella (repo-hygiene fleet/batch capabilities) - #464 — serialization - PR #1023 — **Stacked on #1023, now merged** (its single-repo manifest work is in `main`; this branch merged `main` forward, so the diff is just the fleet-batch changes) Closes #994 --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
… single-walk scan, resolver notes (#1066) ## Summary Batch D small/medium fixes for the `repo-hygiene` clean skill, delivered as one PR. Version bumped `0.5.0` → `0.6.0` with a matching CHANGELOG entry. Closes #996 Closes #997 Closes #998 Closes #1000 Closes #1011 - **#996 — stash lifecycle audit.** New `git-stash-audit.sh` (+ sibling test): per-stash age, source branch, untracked-inclusive diffstat, PR/merge signal, and a per-stash keep/drop advisory. **Never drops a stash** — the agent confirms keep-or-drop per entry, even for a `superseded` advisory. Deduped across linked worktrees by the `--git-common-dir` `StashStore:` key. Runs standalone (`stash` action) and as part of the `git` tier. Related metadata gaps (reflog expiry, `git maintenance`, fsck sweep) are intentionally NOT folded in — they stay tracked in the issue. - **#997 — worktree-attached branches get their own bucket.** `git-branch-audit.sh` subtracts branches checked out in linked worktrees into a distinct `WORKTREE` tier (reason "clean up the worktree first"), routed to the worktree-management tool instead of being lumped into `PROTECTED` or offered for `git branch -d`. Protected-name checks now rank above the worktree check. `Summary:` gains a `worktree=` count. - **#998 — no-upstream classification.** Never-pushed branches get a `git rev-list` count against `origin/<default>`, surfaced as their own REVIEW class and a per-branch `Unpushed:` line (`N ahead of <upstream>` / `no upstream, M commits not on origin/<default>`). Also fixed a latent bug where `rev-parse --abbrev-ref` echoes its input on failure (a configured-but-unfetched upstream) and was mistaken for a real upstream. - **#1000 — resolver notes.** `resolve-clean-action.sh` emits `Note: <trailing text>` when a leading action token is followed by advisory free text (a question or a live-session constraint), documented in SKILL.md as context the agent must address. Trailing text is no longer re-interpreted as an action token, so a note mentioning an action word (e.g. "…include stashes?") no longer forces a false conflict. - **#1011 — single-walk scan.** `scan.sh` migrated off its per-pattern unpruned `find` walks onto the shared `clean_enumerate` / `clean_caches_candidates` / `clean_build_candidates` engine, so the read-only inventory and the mutating caches/build tiers share one prune set (no longer descends `.git/`, `node_modules/`, `.venv/`). Removed the now-unused `CLEAN_FIND_EXCLUDE_VENV` / `CLEAN_FIND_EXCLUDE_NODE_MODULES` vars. ## Verification - All `repo-hygiene` clean `*.test.sh` green; shellcheck (`--rcfile .shellcheckrc`), `shfmt -i 2`, markdownlint, and `check-changelog-parity --check-bump` clean. No issue-refs / TODO markers in code comments (comment-hygiene). - New stash / branch audits run read-only (dry) against a real repo — never a destructive apply: the stash audit surfaces the field's "stale pre-776" stash without false-flagging the main-branch stash; the branch audit classified 15 worktree branches into the `WORKTREE` bucket and surfaced no-upstream branches' unpushed commit counts. - Independent reviewer run on the diff before opening. ## Related - #1003 — umbrella tracking issue for repo-hygiene hardening. - #464 — resolver / SKILL.md / action-router serialization; a parallel Batch C PR (`feat/repo-hygiene-fleet-batch`) also edits `resolve-clean-action.sh`, `SKILL.md`, `action-router.md`, and the CHANGELOG, so merge-order conflicts there are expected and accepted (kept edits surgical to minimize them). - Stacks on #1023 (`feat/repo-hygiene-single-walk-manifest`) — rebase `--onto origin/main` after #1023 squash-merges; at that point reconcile the version/CHANGELOG heading against then-current main (bump to `0.7.0` if Batch C's `0.6.0` landed first). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

Reworks the
cleanskill's build/caches enumeration and apply flow as one coherent change, closing four coupled issues.What changed, per issue
#993 — single pruned walk (perf)
The selective
caches/buildtiers ran ~10 unpruned full-treefindwalks per repo (7 build dir-names + 1 build glob + 1 cache dir-name + 1 cache glob); the! -pathexclusions filtered output but did not-prune, so every walk re-descended.git/,node_modules/, and.venv/. A new sharedclean_enumerateruns one pruned walk per tier that prunes those three trees once and-prints all dir-name and file-glob matches, then classifies protections on the result list.Field measurement (large .NET + node repo, Windows/NTFS): one pruned walk incl.
dusizing ~16.7 s vs a 10-walk unpruned dry-run that exceeded 10 min (killed, never finished). Verified locally that the prune excludes abin/nested innode_modules/and anobj/nested in.venv/.#995 — dry-run manifest, apply consumes it, resume
--dry-runwrites a session-scoped manifest (<class>\t<bytes>\t<relpath>per eligible target), printsManifest: <path>andSummary: planned=N bytes=Kso the confirmation gate can state reclaimable space.--apply --manifest <path>consumes the manifest with a re-stat + re-classify staleness guard (a path that became protected since the dry-run is not removed) instead of re-walking.--apply --manifest <path>; already-removed entries are idempotent no-ops.--applywithout a manifest builds one then applies it, preserving the standalone CLI contract.--include-cachesfolds the caches tier into the one build manifest (noclean-caches.shsubprocess).#1002 — machine-parseable apply summary, fail-closed
Each
--applyends withSummary: removed=N failed=M bytes=K(bytes actually reclaimed) and exits non-zero whenfailed>0, so a fleet sweep no longer needs per-log grepping. Reuses the existingSummary: k=v+[[ failed -eq 0 ]] || exit 1convention (git-branch-audit.sh,git-tree-reset-batch.sh).#999 — drop the
dotnet cleandriverclean-build.sh --applyrandotnet clean <solution>(full MSBuild evaluation, minutes on a large solution) before removingbin//obj/wholesale anyway; the driver added no removal coverage and re-createdobj/evaluation artifacts. Removed entirely — one walk +rmis strictly faster and equally complete — along with itsPlanned: dotnet clean …(dry-run) andDRIVER_FAILED:(apply) output markers and the now-stale driver references in the config/ecosystem/README docs.CLI contract changes (both
clean-build.shandclean-caches.sh)--manifest <path>. On--dry-runit writes the manifest to<path>instead of amktempdefault; on--applyit names the manifest to consume (no re-walk).--dry-run:Manifest: <path>andSummary: planned=N bytes=K.--apply:Summary: removed=N failed=M bytes=K.--applynow exits1when any removal fails (was always0);2usage error and1not-a-git-repo are unchanged.clean-build.shonly):Planned: dotnet clean …andDRIVER_FAILED:are gone.--dry-rundefault,--apply,--include-caches, the protection classes (secrets / runtime deps / skill data preserved by default), and thePlanned remove:/Removed:/Skip (…)line prefixes (the dry-runPlanned remove:line now also carries a human size suffix).Design notes
*.tsbuildinfoinside adist/, or a nestedobj/insidebin/). Anchored on/sobuildnever swallowsbuildstuff.du -sk(POSIX) for sizing; GNU-onlyfind -printfintentionally avoided for BSD/macOS. Scripts stayshell=bash.scan.sh'sTotal reclaimable.Version
0.4.6→0.5.0(minor: new--manifest/resume surface). Per the official plugin-manifest reference,versionis an optional semver string and bumping it is how consumers receive the update: https://code.claude.com/docs/en/plugins-reference (§ plugin.json fields —version: "Semantic version. Setting this pins the plugin to that version string, so users only receive updates when you bump it.").Tests
Extended
clean-caches.test.shandclean-build.test.shTDD-style: manifest path + planned summary, exactclass<TAB>bytes<TAB>pathline format (the manifest is a consumed contract), apply summary + exit 0, resume (removed=0 failed=0, exit 0), nested-dedup single-count,--include-cachesfolding both classes into one manifest, and a chmod-guarded rm-failure case (failed=1, exit 1) that runs on Linux CI and skips on Cygwin where the FS ignores a write-denied parent. All existing repo-hygiene tests remain green; shellcheck, shfmt, markdownlint, editorconfig, changelog-parity, validate-plugins, and check-skill-portability all pass locally.Related
scan.shshares the same unpruned-walk pattern; migrating it ontoclean_enumerateis deferred to this follow-up (not closed here).scan.shis outside repo-hygiene: clean-build/clean-caches run ~10 unpruned full-tree find walks per repo — measured 40x overhead #993's named targets.mainafter it landed.Closes #993
Closes #995
Closes #1002
Closes #999