fix(disk-hygiene): match globs case-insensitively and complete the denial allow-list - #1820
Conversation
|
Claude finished @kyle-sexton's task in 2m 26s —— View job Security Review — PR #1820
SummaryNo security vulnerabilities found. The two functional changes in this PR are both conservative from a security standpoint — one tightens the guard's subcommand gate, the other widens only in the safe direction. Analysis by area:
|
|
Claude finished @kyle-sexton's task in 4m 22s —— View job Security Review — PR #1820
Finding 1 — Test defect: Windows-specific hint asserted without OS mock (will fail on Linux CI)
for name, expected in (
("Thumbs.db", "windows-explorer-metadata"),
("thumbs.db", "windows-explorer-metadata"),
...
):
matched = {hint["id"] for hint in hygiene.matching_hints(name, name, policy)}
self.assertIn(expected, matched, name)
Fix: Wrap the Windows-specific assertions in a Finding 2 — Correctness:
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3ea124c1f5
ℹ️ 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".
|
Claude finished @kyle-sexton's task in 3m 25s —— View job Security Review — PR #1820
No security vulnerabilities found. Analysis by area:
|
|
Claude finished @kyle-sexton's task in 2m 35s —— View job PR Review — #1820
ReviewThe prior Finding 1 (Windows-specific hint tested without OS mock) is correctly resolved by the follow-up commit. Prior Finding 2 (
|
…nc roots (#1818) ## Summary A OneDrive for Business sync root was the single most attractive deletion target in a home audit while reclaiming nothing, and deleting an entry inside it propagates the delete to the organisation's cloud copy. The engine's only structural defense against cloud-sync content was `is_linkish()`, and the dominant dehydrated-placeholder class carries **no reparse bit when read through `os.lstat`** — so the whole subtree was walked and every placeholder was recorded as an ordinary file with `protected_reasons: []`, carrying its **remote** byte count as if it were reclaimable local bytes. Verified on the reporting host before any change: 1,101 files walked, 872 dehydrated placeholders totalling 13,770,936,008 bytes, **0 of 872** flagged by `is_linkish()`. Full reproduction, including the attribute histogram and the `GetFileAttributesW` cross-check, is in the [verification comment](#1804 (comment)). ## Fix **1. `hard_protection()` contributes a `cloud-placeholder` reason** from `FILE_ATTRIBUTE_OFFLINE | FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS`. Placing it there means one predicate covers `scan`, `preview`, `handoff-verify`, and `apply`'s pre-removal recheck at once. It is deliberately independent of the reparse test rather than folded into it — this is precisely the class a reparse test cannot see. Both flags are derived from a single `lstat` per ancestor (`link_and_cloud_state`), so the walk's stat load is unchanged on a scan bounded at 250,000 entries. **`FILE_ATTRIBUTE_RECALL_ON_OPEN` is deliberately excluded**, correcting the issue's own suggested predicate. Its value `0x00040000` is the same number as `FILE_ATTRIBUTE_EA`, and Microsoft documents `RECALL_ON_OPEN` as appearing "only in directory enumeration classes" while every attribute read here comes from `lstat` ([File Attribute Constants](https://learn.microsoft.com/en-us/windows/win32/fileio/file-attribute-constants)). Read through `lstat` the bit means "has extended attributes" — see the measured false positives below. **2. The baseline gains `protected_name_globs`**, holding `OneDrive - *`, matched casefolded through `fnmatchcase` so the verdict does not depend on the host platform's case rules. This half is not optional: probing the same sync root showed the **directories carry no cloud attribute at all** (the root reads `0x31`, all 99 subdirectories read plain `0x10`), so an attribute predicate protects placeholder *files* only and a fully hydrated tenant folder would have no protected descendant and stay deletable. The glob `Dropbox (*)` and the exact names `Dropbox` and `iCloudDrive` ship alongside it. The list is deliberately short, because a protected name applies at **every depth** — a protected directory is never traversed and reports `logical_size: 0`, byte-identical to a genuinely empty directory. Over-protection is not free in a reclamation tool; it silently under-reports. Two candidates named in the issue were **rejected** after checking them: `Box` is a common enough directory name in source trees that protecting it at every depth would make ordinary directories untraversable and silently zero-sized, and `Google Drive` is a legacy Backup-and-Sync name — current Google Drive for desktop streams to a virtual drive letter (`G:` by default on Windows, [Drive for desktop settings](https://support.google.com/drive/answer/13470231)) rather than a profile folder. Dropbox documents both `Dropbox (Personal)` and `Dropbox (<business name>)` as folder names, which is why the bare exact name alone was not enough. Consumers could not have closed this themselves: `protected_exact_names` is not overlay-extensible, and an overlay's `additional_protected_path_globs` are matched against a path *relative to the scan target*, so a standing policy protects such a root only when the target happens to be its parent. Reading the globs from the bundled baseline rather than from the snapshot's policy also means a stale or forged snapshot cannot weaken this protection. **3. Every entry records `file_attributes` and a `size_qualifiers` list**, so a placeholder's remote `logical_size` can never be read as reclaimable local bytes. Additive per-entry trace only — no aggregate's definition changes here, since #1806 asks for the reclaimable-bytes figure specifically and the two should not fight. **4. `SKILL.md` step 2's positional-triage rule** now reads an entry's own `protected_reasons` instead of testing membership of `protected_exact_names`. As written it would have walked straight past a tenant sync root even after this fix. ## Verification **End-to-end, same tenant tree, engine before vs. after:** | | Before | After | |---|---|---| | Files with `protected_reasons: []` | 1,071 (13,893,811,832 bytes) | 229 (236,643,717 bytes — the genuinely local, hydrated files) | | Entries carrying `cloud-placeholder` | 0 | 842 (13,657,168,115 bytes) | 842 rather than 872 because 30 placeholders sit under two `Music` subtrees an existing name protection already truncates. **Depth-1 scan of the user home** — the scenario in the report — `OneDrive - <Org>` moves from `protected_reasons: []` to `baseline-protected-name`, while its siblings are unchanged. Pointing the engine directly at the tenant root now returns `invalid-or-blocked` ("protected shell-folder and profile-hive roots are not valid audit targets") instead of scanning it. **Negative control, 412,270 entries across three non-cloud trees** (a repo checkout, `AppData\Local\Temp`, `~\.claude`): | Predicate | False positives | |---|---| | With `RECALL_ON_OPEN` included (as the issue suggested) | 1,552 — .NET build output and temp `.node` files | | As merged | **0** | Those 1,552 entries are fully present on disk; protecting them would block exactly the artifacts this engine exists to reclaim. **Gates:** - `bash plugins/disk-hygiene/skills/clean/scripts/hygiene.test.sh` — 230 tests, OK (4 skipped); 7 new tests added. - `bash scripts/check-changelog-parity.sh --check` and `--check-order` — pass. - `check-skill.sh clean` — PASS, 0 errors (the one warning is the pre-existing SKILL.md length soft target). - `markdownlint-cli2` on both changed markdown files — 0 errors. - `ruff check` on both changed Python files — clean. No cloud-sync placeholder was deleted or hydrated at any point; every probe used `os.walk` / `lstat` only, and only `scan` was ever run against the tenant tree. **Residual, stated honestly:** single Windows 11 host, one tenant. The four non-OneDrive sync roots were confirmed unprotected by name but their file attributes were never sampled, so they are protected on name alone and their placeholder behaviour is unverified. macOS untested. ## Related - Refs #1806 — asks for the reclaimable-bytes figure and the wider `size_qualifiers` set (`hardlinked`, `sparse`, `not-walked`); this PR establishes the `size_qualifiers` field and adds only the `cloud-placeholder` member, deliberately leaving aggregate semantics unchanged so the two changes do not conflict. - Refs #1805 — the other CRITICAL from the same audit, against the engine gate rather than the engine. **Merge this PR first.** Three PRs from the same audit are open against the `disk-hygiene` manifest and each claims the next version, so they must merge in issue order or the changelog and manifest disagree: | Order | PR | Issue | Version | |---|---|---|---| | 1 | #1818 (this one) | #1804 | `0.11.0` | | 2 | #1819 | #1805 | `0.12.0` | | 3 | #1820 | #1806 | `0.13.0` | Merged in that order the changelog reads contiguously and each conflict is a trivial keep-both-in-order in `CHANGELOG.md`. Merged out of order, a later version lands above a gap and the earlier PRs conflict in a way that looks like an authoring error rather than an ordering one. Fixes #1804 🤖 Generated with [Claude Code](https://claude.com/claude-code) <https://claude.ai/code/session_01RhS3T7ShwJgKTrvk2Mvd3C> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ess clobber (#1822) ## Summary Two independent defects in the statusline tee, both verified by reproduction. The temp-file leak is the reported symptom; the windowless clobber is the sharper one, because it destroys usable data rather than littering. ## Fix **Defect 1 — no crash-safe reclaim of the atomic-write temp file.** Claude Code [cancels an in-flight statusline script](https://code.claude.com/docs/en/statusline) when a new update arrives while the previous one is still running, and a cancellation between the write and the rename left the temp behind permanently. No failed `rm` is needed to explain it: the process never reaches the reclaim line, and the only reclaim paths were write-failure and retry-exhaustion. Two mechanisms, because neither is sufficient alone — the report is right that a trap must not ship as the whole fix: - a trap reclaims on exit and on a catch-able signal; - an age-filtered sweep of leftover siblings on the next refresh recovers what a SIGKILL, a crash, or power loss leaves, which no trap can. The sweep is gated on a **shell glob** rather than on the proposed debounce, which gets the cost property the report wanted without the cadence change: on a clean directory — every refresh in normal operation — it spawns nothing, and it only reaches `find` when a candidate already exists. Its one-minute age floor cannot race a concurrent session's live temp, whose write-to-rename window is sub-second and bounded by the 300 ms retry loop. **Defect 2 — a windowless session overwrote a snapshot that had windows.** On a mixed-auth machine an API-key or enterprise session landed a snapshot with `rate_limits` absent and a **fresh** `captured_at`, so consumers never saw "stale" — they saw a current snapshot with no data and dropped to whole-guard reactive-only, on a machine where a window-bearing session had good data available. The tee now skips the write when this session has no `rate_limits` and the target already has them. Both tests are substring checks — one on buffered stdin, one on the target read with `$(<…)` — so no process is added to the hot path. A windowless session **still** writes when the target has no windows either, so a machine with no window-bearing session keeps an honest staleness signal. ## Verification Reproduced under a throwaway `HOME` with an `mv` shim that parks, so the kill lands inside the write-to-rename window deterministically. | tee variant | SIGTERM | SIGKILL | |---|---|---| | shipped (`origin/main`) | leaks 1 | leaks 1 | | this PR | **0** | leaks 1, reclaimed by the next refresh | Sweep, planting one aged orphan and one live sibling then running a normal refresh: | | shipped | this PR | |---|---|---| | aged orphan reclaimed | no | **yes** | | live sibling spared | yes | yes | | snapshot still written | yes | yes | Windowless clobber: | | shipped | this PR | |---|---|---| | target retains `rate_limits` after a windowless write | **NO** | yes | | `captured_at` after that write | refreshed, hiding staleness | unchanged | **Gates:** - `bash plugins/rate-limit-guard/scripts/statusline-tee.test.sh` — PASS=41, FAIL=0. Seven new assertions: a cancel-mid-window case, the sweep reclaiming an aged orphan while sparing a live sibling and not disturbing the write, and three windowless-write cases. Case 7's existing "no temp-file residue" assertion — which passed while the invariant was broken, because its shim drives only `mv` failure — now has the cancellation stand-in it lacked. - `shellcheck` on the tee — clean; `check-shell-portability.sh` — clean (the test plants an aged file with POSIX `touch -t`, not GNU `touch -d`); `markdownlint-cli2` and `check-changelog-parity.sh --check-order` — clean. ## What this PR deliberately does not do **Suggestion 3, the mtime debounce, is not taken here.** It is the highest-leverage item for latency, and the report's margin analysis (10x against the 600 s staleness rule) is sound — but it is the only suggestion that changes a **contract-visible cadence**: the reader contract requires consumers to arm a Monitor and re-evaluate on every write, because a write is the only signal the windows changed under them. It is a performance change with a contract consequence rather than a defect fix, and the reason it was coupled to the sweep — spawn cost — no longer applies now that the sweep is glob-gated. Bundling a cadence decision into a data-loss fix seemed the wrong trade; it is left for a maintainer, with the issue open. **Suggestion 6, stale-sibling counting in `setup check`,** is likewise left open — though the fix that most reduces its importance is here: the leak is now self-reclaiming, so the condition the freshness probe cannot see is bounded to about a minute instead of being permanent. ## Related - Refs #1806 / PR #1820 — the cross-referenced disk-hygiene half is fixed there: the baseline now carries an `atomic-write-staging-remnant` hint (`*.tmp.*`) matching `.tmp` as an **infix**, so `.rate-limits.json.tmp.<pid>.<random>` hints where it previously matched nothing. - Refs `TODO(#1218)` — the single-account gap the debounce decision touches. Fixes #1807 🤖 Generated with [Claude Code](https://claude.com/claude-code) <https://claude.ai/code/session_01RhS3T7ShwJgKTrvk2Mvd3C> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…mnants
has_protected_name casefolds and matching_hints did not, so on Windows and
macOS — where both spellings name the SAME file — protection was case-robust
while discovery was not. Measured against the shipped baseline: Thumbs.db,
tmp-build, and scratch.md each matched a hint while thumbs.db, TMP-build, and
Scratch.md matched nothing.
Every glob the engine evaluates now goes through one glob_matches() helper —
hints, consumer protection globs, and the protection re-checks in the preview,
verify, and apply lanes — so discovery and protection cannot disagree about what
a name is. The protection-side globs move deliberately rather than by accident,
and casefolding is the safe direction for both roles: a protection glob that
matches more can only keep more, and a hint that matches more can only surface
more for triage, since hints are discovery signals and never verdicts. The
helper casefolds both operands and uses fnmatchcase rather than fnmatch, whose
folding follows the host platform — a matcher whose verdict changes with where
the scan runs is not one a protection can rest on.
*.tmp requires .tmp as a suffix and .claude.json.tmp.* encodes one producer's
exact prefix. Neither matches .tmp as an INFIX before a pid and random suffix,
the standard write-temp-then-rename shape, while the producer-specific hint's
own reason claimed to cover the class. A scan of one sibling plugin's state
directory returned zero hinted entries across 63, of which 61 were remnants of
exactly that shape. A new atomic-write-staging-remnant hint (*.tmp.*, ceiling
medium) covers the class; the producer-specific hint still fires alongside it
because it carries a narrower reason.
Separately, the Bash denial text enumerated four engine subcommands and omitted
the read-only kill-switch probe that _decide allows before the classifier ever
runs, so a consumer learning the allow-list from the denial never learned the
probe is permitted — and the probe is the step that lets the model state the
kill-switch value honestly instead of assuming the default. The denial now names
the probe and discloses the bundled engine's own path, the only route left when
a rendered body's ${CLAUDE_PLUGIN_ROOT} arrives unexpanded. The enumeration and
the grammar are one list: the classifier rejects any subcommand outside
_ALLOWED_ENGINE_SUBCOMMANDS before its own dispatch, and both bundled script
paths come from one accessor each.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RhS3T7ShwJgKTrvk2Mvd3C
The new case-insensitivity test asserted windows-explorer-metadata against Thumbs.db unconditionally, so it passed on the Windows host that wrote it and failed on the Linux CI runner. matching_hints filters by the current OS BEFORE matching, so that row was testing the OS filter rather than the case discipline it is about — the same subtlety that makes the .DS_Store row of the issue's own table not hold on Windows. The OS-agnostic rows now carry the case assertion, and the platform-scoped hints get their own test that pins os_key first, which covers both the Windows and macOS hints on every runner instead of only where they happen to apply. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RhS3T7ShwJgKTrvk2Mvd3C
384b6c8 to
f0f09cd
Compare
|
Claude finished @kyle-sexton's task in 2m 18s —— View job Code Review — PR #1820
Security review is out of scope for this lane (handled by the dedicated security-review runs already posted on this PR, which found no issues). This pass is correctness / architecture / test coverage / maintainability only. CorrectnessBoth changes do what they claim:
Test coverageThe five new tests are well-targeted and the prior Windows-mock defect (flagged by Codex and an earlier automated review) is correctly fixed in One gap, low severity: Maintainability (minor, non-blocking)
def _display_python() -> str:
try:
runtime = Path(sys.executable).resolve(strict=True)
except OSError:
runtime = Path(sys.executable).absolute()
return os.fspath(runtime).replace("\\", "/")Since Alignment with CLAUDE.md / AGENTS.mdNo concerns. This change is plugin-internal logic (glob matching, allow-list enumeration) — it doesn't touch a plugin manifest, marketplace schema, or documented harness/official-docs surface, so the fresh-docs mandate doesn't apply here. The version bump to Note on test executionI was not able to run Summary: No correctness or architecture issues. One low-severity test-coverage gap (protection re-check case-insensitivity only tested at the |
|
Claude finished @kyle-sexton's task in 4m 28s —— View job Security Review — PR #1820
Finding —
|
|
Claude has reviewed this PR 1 time. The lane skips further automatic reviews after 5; deleting this comment resets the count. |
…1870) ## Summary Completes finding **2** of #1806 (byte accounting cannot express "unknown" / "not reclaimable local bytes"). Findings **1** and **5** already shipped in 0.13.0 via PR #1820. - Truncated directories now record `logical_size: null` with a `not-walked` qualifier instead of looking empty (`0`). - Every entry records `nlink` and cheap `allocated_size` (`st_blocks * 512` on POSIX; null on Windows). - Files with `st_nlink > 1` carry `hardlinked`; sparse files carry `sparse` when the platform exposes the signal. - Snapshot / preview / apply / scan-complete report `reclaimable_local_bytes*` as a figure distinct from the walked logical roll-up; qualified entries are excluded. Closes #1806 ## Related - Refs #1806 — findings **3** (`summarize` surface), **4** (Stop-detector marker amortisation), **6** (probe path provenance vs trusted settings), and **7** (run-state retention / snapshot path containment) need design or coupled-grammar calls; left as follow-ups, not in this PR. - Refs #1804 / PR #1818 — landed the `size_qualifiers` / `file_attributes` mechanism this builds on. - Refs PR #1820 — already closed findings 1 and 5 on this issue. ## Test plan - [x] `python -m unittest -v plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py` — 245 tests, OK (4 skipped) - [x] New coverage: truncated-vs-empty contrast, hardlink exclusion from reclaimable, `nlink`/`allocated_size` shape, protected sync-root `not-walked` - [ ] Do not merge from the worker lane --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Summary
Takes findings 1 and 5 of #1806. The issue bundles seven findings; all seven are verified in a comment on the issue, and this PR deliberately does not close it — five findings remain open, three of them needing a maintainer's call rather than an implementation.
Fix
Finding 1 — hint matching was misanchored and case-sensitive in the unsafe direction.
has_protected_name()casefolds andmatching_hints()did not, so on Windows and macOS — where both spellings name the same file — protection was case-robust while discovery was not.All six
fnmatchcasecall sites now go through oneglob_matches()helper: hints, consumer protection globs, and the protection re-checks in the preview, verify, and apply lanes. That is the part the issue flagged as needing deliberate handling — the protection globs move with the hints rather than by accident. Casefolding is the safe direction for both roles: a protection glob that matches more can only keep more, and a hint that matches more can only surface more for triage, since hints are discovery signals and never cleanup verdicts. The helper casefolds both operands and keepsfnmatchcaserather than switching tofnmatch, whose folding follows the host platform — a matcher whose verdict changes with where the scan runs is not one a protection can rest on.A new
atomic-write-staging-remnanthint (*.tmp.*, ceilingmedium) covers the class the producer-specific hint's ownreasonalready claimed:.tmpas an infix before a pid and random suffix, the standard write-temp-then-rename shape. The producer-specific hint still fires alongside it, since it carries a narrower reason.Finding 5 — the Bash denial text under-reported the allow-list. It enumerated four engine subcommands and omitted the read-only kill-switch probe that
_decideallows before the classifier ever runs. Since the documented bootstrap path is to submit a wrong shape so the denial teaches the grammar, a consumer learning the allow-list from the denial never learned the probe is permitted — and the probe is the step that lets the model state the kill-switch value honestly instead of assuming the default. The denial now names the probe and discloses the bundled engine's own path, the only route left when a rendered body's${CLAUDE_PLUGIN_ROOT}arrives unexpanded and the exact-path identity check denies every guess.The enumeration and the grammar are now one list, as suggested:
classify_exact_engine_commandrejects any subcommand outside_ALLOWED_ENGINE_SUBCOMMANDSbefore its own dispatch, and both bundled script paths come from a single accessor each, so the message cannot teach a grammar the classifier does not implement.Verification
Running
matching_hints()against the shipped baseline, before and after:.rate-limits.json.tmp.1363789.17391atomic-write-staging-remnantsettings.json.tmp.4atomic-write-staging-remnant.claude.json.tmp.9552.9bfba4e83eaaclaude-json-failed-atomic-writethumbs.dbwindows-explorer-metadataTMP-buildcommon-temp-directoryScratch.mdscratch-artifactThumbs.db,tmp-build,scratch.md, andfoo.tmpare unchanged, so this widens matching without disturbing what already matched.One correction to the issue's table: the
.DS_Storerow does not hold on Windows.macos-finder-metadatadeclares"os": ["macos"]andmatching_hints()filters by current OS before matching, so neither spelling hints on a Windows host. That is correct OS filtering, not a defect; the case finding stands on the other five rows.Gates:
bash plugins/disk-hygiene/skills/clean/scripts/hygiene.test.sh— 228 tests, OK (4 skipped); 5 new tests covering case-insensitive hints, the staging-remnant class, case-insensitive consumer protection globs, the denial text naming every accepted shape, and the classifier rejecting a subcommand outside the shared list.bash scripts/check-changelog-parity.sh --check-order— pass.check-skill.sh clean— PASS, 0 errors;markdownlint-cli2— 0 errors;ruff check— clean.Related
size_qualifiersmechanism is landing in PR fix(disk-hygiene): hard-protect cloud-sync placeholders and tenant sync roots #1818, and the rest should build on that rather than race it), finding 3 (asummarizesurface — a new subcommand and a new shape in the guard's grammar, a design call), finding 4 (theStopdetector's marker — both remedies change when a safety-observability detector goes quiet, a maintainer call), finding 6 (the probe's environment channel — the probe's allow-list requires exactly two tokens, so the probe, the guard, andSKILL.mdhave to change together), and finding 7 (run-state retention — a durability policy call).0.13.0(0.11.0and0.12.0are claimed by fix(disk-hygiene): hard-protect cloud-sync placeholders and tenant sync roots #1818 and fix(disk-hygiene): deny the different-file escape to this plugin's own cache tree #1819). Merged in issue order the changelog reads contiguously.No linked issue
🤖 Generated with Claude Code
https://claude.ai/code/session_01RhS3T7ShwJgKTrvk2Mvd3C