feat(disk-hygiene): gate unbounded scans of known-large targets at the engine - #1010
Conversation
|
Claude finished @kyle-sexton's task in 3m 47s —— View job PR Review: feat(disk-hygiene): gate unbounded scans of known-large targets
This is a well-designed safety backstop. The code is correct, the guard grammar is tight, and the test coverage is solid. A few observations below — none are blockers, but a couple are worth considering. ArchitectureThe gate placement in Engine logic (
|
| Test | What it proves |
|---|---|
test_home_target_without_bound_requires_confirmation_and_never_walks |
Gate fires, exit 5, no walk, no snapshot written |
test_home_target_with_max_depth_proceeds |
--max-depth bypasses gate, scan completes |
test_home_target_with_confirmed_flag_proceeds |
--confirmed-large-scan bypasses gate, scan completes |
test_ordinary_subdirectory_is_not_gated |
Non-home target unaffected |
The _home_target_fixture fixture count (loose.tmp + nested = 2 entries) matches the assertion self.assertEqual(2, payload["immediate_entries"]). ✓
One missing case: the tests don't cover what happens when top_level_entry_count fails (i.e., os.scandir raises OSError). In that case probe_error would be non-null and immediate_entries would be None. The gate still fires correctly (this is just a probe for the informational output), but a test demonstrating the graceful degradation would add confidence.
Documentation
SKILL.md section 1 accurately describes the engine gate and both bypass paths. The instruction to use AskUserQuestion before --confirmed-large-scan mirrors the apply lane's human-confirmation bar. The deferred drive-root concern noted in the PR body (#984) is not in the code inline — a brief comment near large_scan_reasons noting the extension point would make the deferred trigger discoverable for future contributors, though this is a minor style preference.
safety-model.md paragraph is accurate and clearly distinguishes scan-cost gating from hard-rejection of invalid targets.
Summary
No correctness bugs. The design is sound and the implementation is faithful to the PR description. The three items most worth acting on:
- Missing
probe_errorfailure test — low priority, but would complete coverage of thetop_level_entry_countfailure path. Fix this → --data-rootrequired even for gate-only path — consider noting this inSKILL.mdso the engine-call block is unambiguous.- Deferred trigger for drive roots — a one-line comment near
large_scan_reasonspointing to the#984extension point would help future contributors.
Ready to merge as-is from a safety and correctness standpoint.
|
Claude finished @kyle-sexton's task in 4m 12s —— View job Security Review — PR #1010No critical or important security vulnerabilities found. The changes are security-positive overall. One suggestion-level finding follows. Reviewed filesGuard flag-stripping analysisThe new optionals = list(tokens[7:])
confirmed = optionals.count("--confirmed-large-scan")
if confirmed > 1:
return None
if confirmed:
optionals.remove("--confirmed-large-scan")
if len(optionals) not in {0, 2, 4, 6, 8} or not _consume_optional_pairs(...):
return None
No bypass found. Home-directory comparison analysis
No bypass found through path manipulation. SUGGESTION — Gate silently passes when HOME / USERPROFILE is absent from the environmentSeverity: SUGGESTION | Confidence: LOW def user_home() -> Path | None:
try:
return Path.home()
except RuntimeError:
return None
Mitigating factors: (a) The agent cannot unset HOME through the Bash guard — a command like A hardened approach would fall back to Other areas checked — no findings
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b54f81b97d
ℹ️ 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".
…e engine The scan lane had no engine-level backstop for known-large targets: SKILL.md merely asked the invoking agent to add --max-depth 1 on a large root. A forgetful or differently-tuned agent could start an unbounded recursive walk of the user home directory unconfirmed — only the destructive apply lane was gated, not the scan's own time/resource cost. Detect the user home directory deterministically in the engine. A scan of that target without --max-depth or the new --confirmed-large-scan flag now performs a cheap top-level probe and returns large-target-confirmation-required instead of walking, mirroring the apply lane's "ask before it is expensive" posture. The Bash guard accepts the valueless --confirmed-large-scan in the exact scan shape. Drive-root reasoning stays out of scope (companion issue #984). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SFq1q99cNeZjKhDzHv2BQw
b54f81b to
fe827cd
Compare
|
Claude finished @kyle-sexton's task in 2m 59s —— View job PR Review: feat(disk-hygiene): gate unbounded scans of known-large targets
The design is sound and the implementation is faithful to the PR description. One correctness issue worth addressing before merge, plus two informational observations. ArchitectureGate placement in Issue: path comparison is string-based, not filesystem-identity-basedif os.path.normcase(os.fspath(target)) == os.path.normcase(
os.fspath(resolved_home)
):
The correct comparison for filesystem identity is try:
if os.path.samefile(target, resolved_home):
reasons.append("user-home")
except OSError:
pass # resolved_home unreachable — fail open, gate does not fireThe This gate exists to prevent accidental expense rather than to enforce a security boundary (an agent can always bypass with Guard logic (
|
| Test | What it proves |
|---|---|
test_home_target_without_bound_requires_confirmation_and_never_walks |
Gate fires, exit 5, no walk, no snapshot written |
test_home_target_with_max_depth_proceeds |
--max-depth bypasses gate, scan completes |
test_home_target_with_confirmed_flag_proceeds |
--confirmed-large-scan bypasses gate, scan completes |
test_ordinary_subdirectory_is_not_gated |
Non-home target with a different mocked home is unaffected |
The fixture count (loose.tmp + nested/ = 2) matches the immediate_entries == 2 assertion. ✓
mock.patch.object(hygiene.Path, "home", return_value=root.resolve()) correctly patches at the class level before user_home() calls it. ✓
One missing path: the probe_error failure case — when os.scandir raises OSError, immediate_entries is None and probe_error is a non-empty string. The gate still returns exit 5 correctly, but a test demonstrating graceful degradation would complete coverage. Low priority.
Documentation
SKILL.md section 1 accurately describes the engine gate, the two bypass flags, and the AskUserQuestion requirement before --confirmed-large-scan. The instruction is well-calibrated: --max-depth is presented as the preferred response, --confirmed-large-scan as the human-confirmed override.
safety-model.md correctly distinguishes scan-cost gating (time/resources) from hard rejection of invalid targets (system roots, mount points). The paragraph is clear and accurate.
CHANGELOG entry is minimal and mergeable. Version bump 0.4.7 → 0.5.0 is appropriate for a new engine gate.
Summary
One item to address before merge:
os.path.normcaseis a POSIX no-op — useos.path.samefileinstead (gate misses case-mismatched home paths on macOS). Small fix, high correctness value.
Two informational items (no action required):
- Missing
probe_errorfailure test — theOSErrorpath throughtop_level_entry_countis untested but correct by inspection. --data-rootis required even in the gate-only path —state_output_path()is called at line 1610 before the gate fires. Pre-existing design; consumers passing only--targetwithout--data-rootget an early error rather than the gate response. Not a regression, but worth a note inSKILL.md's command block.
|
Claude finished @kyle-sexton's task in 3m 0s —— View job Security Review — PR #1010
No critical or important security vulnerabilities found in the new code. One finding follows — it echoes the Codex P2 comment with additional analysis on exploitability. SUGGESTION — Gate comparison misses case-insensitive macOS filesystemsSeverity: SUGGESTION | Confidence: MEDIUM if os.path.normcase(os.fspath(target)) == os.path.normcase(
os.fspath(resolved_home)
):On POSIX (including macOS), An agent that passes the home directory with any differing capitalization receives an unbounded scan instead of the gate response. Mitigation requires no user-visible change in normal operation because correctly-cased paths still compare equal. Recommended fix: Replace the string comparison with try:
if target.samefile(resolved_home):
reasons.append("user-home")
except OSError:
pass # if home is unavailable, fail open (safe — this is cost gating, not data-loss gating)Mitigating factors keeping this at SUGGESTION rather than IMPORTANT:
Areas checked with no findingsGuard flag-stripping — Path manipulation via Home-absent environment — The prior Claude security comment covered this in detail (LOW confidence suggestion). The guard's shell-operator rejection ( No new subprocess paths — No GitHub Actions / workflow changes — The seven changed files are all within the plugin skill; no Test fixture isolation — New tests mock Docs — No misleading guidance. |
The large-target scan gate compared the target to the home directory with os.path.normcase string equality. normcase folds case only on Windows, so on a case-insensitive macOS volume a case-variant spelling (/users/alice vs the real /Users/alice) resolves to the same directory yet compares unequal — bypassing the gate and starting the unbounded walk the gate exists to prevent. Match by filesystem identity instead: os.path.samefile compares device + inode, which is robust to case-insensitivity and path spelling on every platform. target existence is validated upstream; samefile only raises on a missing path, so a home that cannot be stat'd is treated as no match rather than crashing the scan. Adds a regression test that proves the match uses samefile (identity) for two case-differing spellings, and a test that an unstattable home is a safe no-match. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SFq1q99cNeZjKhDzHv2BQw
|
Claude finished @kyle-sexton's task in 1m 48s —— View job PR Review: feat(disk-hygiene): gate unbounded scans of known-large targets
The synchronize commit ( Fix:
|
|
Claude finished @kyle-sexton's task in 3m 33s —— View job Security Review — PR #1010
No critical or important security vulnerabilities found. The second commit (55caf1e) correctly resolves the macOS path-comparison bypass identified by prior reviews. One suggestion-level note follows, then a full areas-checked list. Guard grammar —
|
|
🔒 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. |
Reconcile disk-hygiene conflicts from concurrent main advances (#1010, #1012, #1028, #1031) that landed while this branch was open. - plugin.json / CHANGELOG: restack this branch's fix onto main's version line, 0.4.8 -> 0.6.3 (next patch above main's landed 0.6.2); preserve every landed CHANGELOG entry (0.6.2 / 0.6.1 / 0.6.0 / 0.5.0) with this branch's entry on top. - clean/SKILL.md: compose #983's corrected kill-switch framing (a skill-scoped hook receives neither ${user_config.*} nor CLAUDE_PLUGIN_OPTION_*, so the guard cannot enforce audit-only) with #1012's deterministic kill_switch_probe read, dropping main's stale "guard enforces via --disk-hygiene-enabled" claim that #983's hook-arg change (only --plugin-root) invalidated. - clean/reference/safety-model.md: keep #983's --data-root derivation-from- plugin-root rewrite and append #1010's --confirmed-large-scan grammar note. destructive_guard.py and test_hygiene.py auto-merged (data-root derivation + --confirmed-large-scan + kill_switch_probe allowlist + MIN_PYTHON floor); full suite 98 passed, 4 platform-skipped.
…ket-denying non-OS drives (#1063) > [!NOTE] > Supersedes #1026 — same fully reviewed, CI-green content rebuilt as a single commit on current > main (version reconciled to 0.6.4 over #1014's 0.6.3). See #1026 for the complete review history; > all its review threads were resolved there. ## Summary `disk-hygiene`'s `clean` skill rejected **any** filesystem/volume root as an audit target purely structurally, with no reasoning about the volume's purpose. This wrongly blocked a legitimate non-OS volume such as a Windows **Dev Drive** (`D:\`, ReFS, no OS content) with no path to override, interrogate, or explain. Root classification is now **reasoned**: a genuine OS-managed root stays denied; a non-OS volume root becomes a valid target that composes with the large-target scan gate. Fixes #984 ## Root cause (corrected from the issue's premise) The issue cited `hard_protection()` (the `path.parent == path` branch) as the blanket rejection. Empirically, that branch is **dead code for drive roots** in the live call graph — candidates are validated non-root descendants of the target, so it never fires on a drive root. The real user-facing block on Windows was the **mount-point check** in `main()` scan: every Windows drive letter is `os.path.ismount() == True`, so `D:\` was rejected as a "mount point" *before* the root check the issue points at (verified live: `os.path.ismount('D:/') == True`; scanning `D:\` returned `{"error": "mount points are not valid audit targets"}`). Fixing only the cited line would have left the bug unfixed. `preview()` applied the same checks in a different order, a latent inconsistency. ## Fix - **Reasoned OS-managed classification** (`is_os_managed_target`): a root is denied when it is *within* an OS-managed root (full `system_roots()` set) or *holds an existing* OS-install marker (`os_drive_markers`). The OS-install markers deliberately **exclude the per-volume metadata every Windows volume carries** (`System Volume Information`, `$Recycle.Bin`) — a Dev Drive has those too, so counting them would misclassify every drive root as OS-managed. `WINDOWS_VOLUME_SYSTEM_NAMES` is split into `WINDOWS_OS_DRIVE_MARKERS` ∪ `WINDOWS_PER_VOLUME_METADATA`; the union is unchanged, so per-entry protection of those folders on every drive is preserved. - **Mount rejection scoped to non-root mounts** (`mounted and not is_volume_root(target)`): a volume-root target (inherently a mount point) falls through to the reasoned root logic; nested and bind mounts stay hard-blocked — the real cross-boundary danger. - **Scan and preview unified** to one `unverified → OS-managed → non-root-mount` target-check order, removing the latent ordering inconsistency. - **Composes with the large-target gate (#985 / #1010), reusing its vocabulary — no parallel gate.** `large_scan_reasons` now names a non-OS volume root a known-large root (reason `non-os-volume-root`), so an unbounded whole-volume walk returns `large-target-confirmation-required` (exit 5) unless bounded with `--max-depth` or confirmed with `--confirmed-large-scan`. No `destructive_guard` change is needed — #1010 already permits that flag. - `hard_protection`'s (dead-for-drive-roots) branch is made reasoned rather than blanket, for faithfulness, and locked with a direct unit test. Deletion safety is unchanged: per-entry OS-managed / mount / VCS / identity protections, the preview, and per-tier approval all still gate any removal. The design **degrades safely** — if OS-drive markers were somehow absent on a real OS drive, the result is confirmation-required plus full downstream gating, never a silent dangerous delete. A stray non-OS `D:\Windows` folder classifies the volume as OS-managed (conservative false-positive deny), which is acceptable. ## Test evidence - `test_hygiene.py`: **101 tests pass** (added classification, `os_drive_markers` metadata-exclusion, `hard_protection` reasoning, non-root-mount rejection, OS-managed deny in scan+preview, and non-OS-volume-root large-target-gate tests; existing `system_roots` protection test stays green). - `ruff check` clean; `python -m py_compile` clean. - Repo gates green: `check-changelog-parity --check-bump`, `check-changed-skills` (skill-quality static contract — 0 errors), `validate-plugins` (manifests + catalog), `check-orphaned-fixtures`. - **Live verification on the audited machine**: `C:\` → denied (`OS-managed roots are not valid audit targets`); `D:\` (Dev Drive) with no bound → `large-target-confirmation-required` (reason `non-os-volume-root`); `D:\ --max-depth 1 --confirmed-large-scan` → `scan-complete`. ## Independent review Reviewed by a fresh-context reviewer (rationale withheld, adversarial). Verdict: **classification core sound** — every branch hand-traced, the pathlib root-identity edge verified empirically, no path where an OS drive slips through as non-OS or a Dev Drive is wrongly denied, and no deletion-safety regression. No code changes required. Three low-confidence advisories, noted for transparency (no action taken): 1. `hard_protection`'s volume-root branch is unreachable on the live call path (candidates are validated non-root descendants) — kept as defense-in-depth and locked by a direct unit test. 2. `system_roots()` and `os_drive_markers()` each call `windows_drive_roots()`, so a scan makes a couple of redundant `GetLogicalDrives` syscalls per invocation — negligible, not hot-path. 3. Known limitation: a stray `Recovery` (or other OS-install-marker-named) folder on a non-boot drive would classify that volume as OS-managed. This fails safe (over-restrictive deny, never an unsafe allow) and is the conservative side to err on. ## Fresh-docs note Per the repo's fresh-docs mandate: this change is Python engine logic that reuses existing status vocabulary and adds no new plugin component type, manifest field, hook, or skill surface — no official Claude Code plugin-schema page is load-bearing here, so none is cited. The only external behavior reused (`--confirmed-large-scan`, `large-target-confirmation-required`) is this plugin's own #1010 contract, referenced directly. ## Related - #983, #985, #986 — sibling issues from the same disk-hygiene audit, addressed in parallel PRs. - #985 (PR #1010) — the companion large-target scan gate; this PR builds on its `large_scan_reasons` / `--confirmed-large-scan` mechanism rather than adding a parallel gate. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01SFq1q99cNeZjKhDzHv2BQw Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Root cause
The
scanlane had no engine-level backstop for known-large targets.SKILL.mdonly asked the invoking agent to add
--max-depth 1on a large root — aprompt-level convention. If the agent forgets, skips it, or a differently-tuned
future agent weights that instruction differently, an unbounded recursive walk of
$HOME/%USERPROFILE%runs unconfirmed. Only the destructiveapplylane wasgated; the scan's own time/resource cost was not. The home directory is a valid
target today (its basename — the username — is not a protected shell-folder name),
so nothing stopped the full walk.
Fix
Detect the user home directory deterministically in the engine (
large_scan_reasons,exact resolved-path equality — descendants like
~/projectsstay ungated). A scanof that target carrying neither
--max-depthnor the new--confirmed-large-scanflag now does a cheap top-level
os.scandirprobe and returnslarge-target-confirmation-required(exit 5) instead of walking, mirroring theapply lane's "ask before it is expensive" posture — moved earlier because the cost
here is time, not data loss.
--max-depthis the preferred bounded response;--confirmed-large-scanis the explicit, human-confirmed override (SKILL.md directsthe agent to
AskUserQuestionfirst).The change is an engine backstop reached through the Bash guard, so the guard's
strict scan grammar had to accept it:
destructive_guard.pynow strips at most onevalueless
--confirmed-large-scanfrom the scan optionals and validates theremainder as the existing flag/value-pair grammar, keeping the shape exact
(duplicate flag or a trailing value is denied).
Docs updated:
SKILL.mdsection 1 (engine gate + the two flags, command block),safety-model.md(scan-cost gate paragraph + the new guard flag). The prompt-level--max-depth 1guidance stays, now backed by the engine.Tests
test_hygiene.py(86 tests pass, 4 platform-skips):test_home_target_without_bound_requires_confirmation_and_never_walks— assertslarge-target-confirmation-required, exit 5,refuse_call("scan_tree")proves nowalk, and no snapshot file is written.
test_home_target_with_max_depth_proceeds/test_home_target_with_confirmed_flag_proceeds— both reach
scan-completeand write the snapshot.test_ordinary_subdirectory_is_not_gated— a non-home target is unaffected.test_guard_scan_accepts_single_confirmed_large_scan_flag— guard allows the flag(alone, with
--max-depth, with--policy) and denies a doubled flag or atrailing value.
Verification
bash plugins/disk-hygiene/skills/clean/scripts/hygiene.test.sh→ 86 pass / 4 skip.ruff check(0.15.20) → all checks pass on the three scripts.bash scripts/validate-plugins.sh→ all manifests + catalog validate.markdownlint-cli2on the three changed docs → 0 errors.ruff formatdrift on lines this PR does not touch (a 0.15.20-vs-pinned-0.15.21quirk; disk-hygiene's
.test.shruns unittest only) is intentionally left alone; my addedlines are ruff-canonical.
Scope / residual risks
hard_protectionroot-rejectionis untouched.
large_scan_reasonscovers onlyuser-home; drive roots are rejected upstreamtoday, so coverage is complete now. Deferred trigger: if disk-hygiene: blanket 'any filesystem root' rejection has no reasoning; blocks legitimate non-OS volumes (e.g. Windows Dev Drive) #984 makes a reasoned drive root
a valid scan target, extend
large_scan_reasonsto cover it, or an unbounded drive-root walkwould bypass this gate.
Fresh-docs mandate
No official docs fetched: the change is internal Python engine/guard logic plus skill/reference
prose, touching no documented plugin/hook/manifest schema (the semver
versionbump is not aschema change). The fresh-docs mandate targets schema/behavior changes against upstream docs, which
this PR does not make.
Related
hard_protectionroot-rejection (see Scope above).Fixes #985
🤖 Generated with Claude Code
https://claude.ai/code/session_01SFq1q99cNeZjKhDzHv2BQw