Skip to content

disk-hygiene:clean — ceremony does not scale to stakes: audit of a real --execute run (13 live findings) #3347

Description

@kyle-sexton

plugins/disk-hygiene, skill clean, v0.20.21. Windows 11, PowerShell 7 + Git Bash.

Produced by /plugin-quality:audit after a real --execute run against a user profile: 17 paths, 8,529,560 bytes removed on a machine with 3.71 TB free. Every path was verified clear immediately before deletion; nothing was skipped or wrongly deleted. The safety machinery worked correctly throughout.

It cost roughly 50 tool calls, ~34 permission prompts, and about an hour. The operator's verbatim assessment: "Good god this is all performing so poorly."

Correct behavior, unacceptable experience. That gap is what this tracks.

Sealed evidence packet (findings, controls, withdrawals, validation):
~/.claude/plugins/data/plugin-quality-melodic-software/evidence/<session>/disk-hygiene-clean/20260824T165142Z/

Severity table

Ships evidence-3.md's revised ordering plus F14, not audit-notes.md's original table, which the audit itself superseded.

ID Severity Finding Scoped issue
F14 high No proportionality mechanism, and three rules forbid one this issue
F10 high Always-on gate costs ~2.4 s per shell call in every session #3349
F13 medium-high Belt and engine gate are indistinguishable, opposite scopes #3348
F3 medium-high Platform gate excludes macOS whole-lane, not per-primitive this issue
F9.3 / F9.4 medium-high No belt escape hatch; Bash deny-by-default exceeds its threat model this issue
F1 medium "Final human permission prompt" is a hook ask; docs do not guarantee a human #3350
F2 medium Verify-one-delete-one conflates freshness with model round-trips #3351
F5 medium Provenance and ranking mandated with no mechanism supplied this issue
F7 medium Pipe rejection is correct; no output-verbosity flag exists #3352
F8 medium handoff-verify lacks the container semantics Linux apply has this issue
F6 low-medium Managed-state exclusion contradicts the plugin's own baseline policy this issue
F11 low-medium Residual fail-open when no Python resolves (disclosed) this issue
F12 low-medium Telemetry no-op unless HOOK_TELEMETRY_SINK set; no auditable decisions this issue
F4 informational Most per-path model work is already engine work this issue

Withdrawn during the audit: F9.1 and F9.2, plus Blindspot 5's attribution. A two-cell control falsified the original single-observation inference. They are listed here so the count is not read as 14 live findings.

F14 — the headline

shutil.disk_usage appears exactly twice in the 149 KB engine (hygiene.py:3201, :3314), both inside apply, read after every decision purely to populate observed_free_space_delta_bytes. It gates nothing, ranks nothing, skips nothing. And apply is Linux-only, so on Windows the engine never reads free space at all — the operator's 3.71 TB is invisible to the component on their platform.

The gates that exist (large-target-confirmation-required, --max-depth) scale with scan cost; safety-model.md:445-451 says so explicitly. Three rules then make proportionality unreachable by design:

  • SKILL.md:33-35 — "reclaimed bytes are secondary"
  • SKILL.md:208-209 — "byte size is never a ranking key"
  • SKILL.md:264-265 — "Never treat a low or zero reclaimable-byte figure as a reason to skip a finding"

Each is individually defensible and each earned its keep in this run. Jointly they mean the component cannot distinguish a 3.7-TB-free tidiness pass from a disk-full emergency, and is instructed not to try.

Per-path ceremony is a constant, so cost scales with finding COUNT, not finding VALUE. That makes a 17-small-item tidiness pass the worst case the design produces — and the most common one.

This is not a neutral tax; it directly causes residue. Two empty directory trees were knowingly left on disk rather than pay the two-scan cost again. The skill's stated primary objective is tidiness, and its cost model defeated that objective.

A related gap: the skill has no notion of regenerable at a cost, so a 2.78 GB rebuildable build cache and 2.78 GB of irreplaceable data rank identically.

Remaining findings tracked here

F3execution_blockers gates on os_key() != "linux" whole-lane rather than per-primitive, excluding macOS despite it having dirfd + lsof. Decline is dated and affirmed (#1116) with a stated reversal trigger.

F9.3 / F9.4 — No escape hatch for the belt; the kill switch makes it stricter, not looser. Bash deny-by-default exceeds its threat model and inverts on Windows, where the hard-denied lane cannot delete anyway. Disclosure alone is not a remedy for a session-long lockout with no lever. Note the belt does not reach subagents — an earlier finding to the contrary was withdrawn — and that non-inheritance is undocumented and build-specific, so documenting it as a recovery lane needs that caveat.

F5 — Provenance and ranking are mandated with no mechanism. Hint coverage was 1 of 76 entries. What actually worked was ad-hoc, reproducible, and computable from data the snapshot already holds:

  • a PID embedded in a staging filename, cross-checked against the live process list
  • gaps in a numbering sequence marking an already-abandoned set
  • identical byte lengths across four files revealing one repeated HTTP 429 body
  • a shared write-second between a leaked write-probe and its sibling config
  • a log line naming the writer (python/3.14.6; huggingface_hub/1.27.0; hf_xet/1.6.0)

Sibling-mtime clustering and size-collision detection are computable today.

F8handoff-verify lacks the bottom-up container semantics Linux apply already implements. A directory cannot be named inside its own snapshot and is truncated-not-inventoried in its parent's until a rescan walks it empty, so removing one empty directory took two full scan rounds.

F6 — The managed-state exclusion contradicts the plugin's own baseline policy, which ships a .pulumi-write-test-* hint it then makes engine-ineligible. Two pieces of genuine residue found inside managed directories will never be reclaimed by their owners. --vcs-evidence is the in-tree precedent for a narrow evidence-gated exception.

F11 — Residual fail-open: if no Python resolves, the guard exits 0 silently; only a Stop-hook detector reports it, after the fact. Disclosed by the plugin.

F12 — Telemetry is a no-op unless HOOK_TELEMETRY_SINK is set, and the data root holds only runs/, so no guard decision is auditable after the fact.

F4 — Most per-path model work is already engine work; genuine per-OS divergence is small. One script with per-OS branches, which the engine already is.

Remediations not covered by a scoped issue

  • Append a durable guard-decision line to CLAUDE_PLUGIN_DATA (F12).
  • Document the belt's recovery lanes, including the subagent one, and add an escape hatch (F9).
  • Write down the five provenance techniques above; emit sibling-mtime clusters and size collisions from existing snapshot data (F5).
  • Give handoff-verify container semantics mirroring Linux apply (F8).
  • A stated batch posture for low-stakes targets (F14) — relaxes orchestration, not checks.
  • Narrow the Bash belt from deny-all to deny-mutation (F9.4); split the platform gate so macOS fails on mountinfo specifically (F3).
  • Reopen chore(disk-hygiene): affirm recorded decline of Windows engine-execution lane (F12) #1116 with F1 as new input. An interleaved verify-and-delete subcommand closes F2, F3 and most of F14 together and is stricter than today's model-mediated gap — but it is a Windows deletion lane and belongs to the maintainer under the full security review the existing reversal trigger specifies.

What the component gets right

Most of the sharpest findings above were possible because of this: the documentation is unusually honest. It names its own fail-open, states that coverage is enumerated rather than complete, dates its declines, and pre-warns that observed free-space deltas are inexact.

Its byte-accounting discipline prevented a false claim during this run — an unqualified recursive size sum reported a Docker directory at 54.84 GB, and the insistence on size_qualifiers forced a sparse check before that number could be used. Its name-is-not-a-verdict rule prevented a real mistake: .codex/.tmp (6,270 files, 134 MB) reads as scratch and is live plugin state.

Where it goes wrong is asserting a human where the docs establish a hook (F1), and describing one guard with the other's message (F13).

Limits of this audit

  • Single platform. The Linux apply lane was never exercised.
  • Single run, single target.
  • One permission mode. F1's experiment measured the session's own mode only.
  • test_hygiene.py (356 KB) was not read. Some remediation proposed here may already be tested-as-unsupported.
  • No 0.20.20 → 0.20.21 diff, so nothing is characterized as a regression versus long-standing behavior.
  • Not audited: the sibling setup skill's workflow, evals/, killswitch_config.py scope resolution.
  • F10's number is n=5 on one machine, Git Bash on Windows, range 817–2,657 ms (3.2x spread), no plugin-disabled control.
  • Methodological note: two findings were single-observation inferences that a later control falsified. They were withdrawn rather than shipped.

Findings came from a fresh-context auditor that never saw the reasoning of the session under audit, then were independently re-validated by two further fresh-context validators that never saw each other's verdicts.

🤖 Generated with Claude Code

Metadata

Metadata

Assignees

No one assigned

    Labels

    needs-triagepriority: needs-triageDefault until a priority tier is assigned.work-class: read-onlyAudits, research, reports. No repository mutation; tracker and queue writes only.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions