TITLE
disk-hygiene:clean: frontmatter Bash belt is session-lifetime, not skill-scoped (+6 findings from a post-use audit)
BODY
A post-use audit of the disk-hygiene:clean skill turned up seven findings, one of them a false
statement of security posture with real blast radius: the skill's frontmatter PreToolUse belt is
documented in three places as scoped to "while the clean skill is active", but Claude Code registers
frontmatter hooks for the rest of the session, so its deny-by-default Bash lane stays armed long
after cleanup ends. The remaining six range from a scan boundary-condition bug to stale Gotchas that
contradict the shipped frontmatter. Every finding below was re-verified against 0.17.9 source by a
reviewer who was not in the run.
Provenance. Plugin disk-hygiene v0.17.9, skill clean, marketplace install. Real attended run
on a Windows 11 Dev Drive (D:\, non-OS volume root, 87 GB used / 937 GB free): two invocations
(audit-only, then --execute), 13 scan calls, 11 single-path handoff-verify calls, six removal
rounds, one permanent long-path deletion. Windows manual-handoff lane only — the engine apply path
never executed. Findings were produced by a fresh-context auditor reading the packet without the run
transcript, then independently re-verified against source before filing.
Source citations are repo-relative to plugins/disk-hygiene/.
What worked
Recorded first and with citations, because five mechanisms held under real pressure and a reader
skipping to the findings would get a false picture of this component.
- The exact-tier-and-list confirmation gate held —
skills/clean/SKILL.md:84-111. One surface
rule, one floor, and a per-question table of what an answer must name, plus the rule that a bar
may only require naming something the skill already showed. The run produced three bare "Approved"
replies; two were resolved by asking for the naming, and for the single irreversible action the
gate was held firm until a named confirmation arrived. A yes/no gate could not have drawn that
distinction — the naming bar is what made it expressible.
- Per-path
handoff-verify before each deletion — skills/clean/SKILL.md:305-310. The staleness
argument is given in full (the first path's check ages while later paths are still being walked,
and staler after each intervening deletion), and the multi-path form is explicitly reserved for
reporting. Observed: 11 single-path runs for 11 deletions, zero multi-path verdicts used for
execution. The subcommand restates the rule in its own note field on every call — in-band
reinforcement that does not depend on the model remembering the skill body.
- Managed-state routing held against 29 GB —
skills/clean/SKILL.md:55-57. Managed state is
categorically ineligible "even when a native dry-run calls it eligible", which is strong precisely
because it forecloses the most tempting argument. Observed: D:\packages (29.29 GB across nine
package managers), D:\.pnpm-store, and D:\repos were routed and never planned; D:\Users was
withheld from a tier while it looked chezmoi-owned and reclassified only on evidence.
truncated_paths coverage-gap reporting — skills/clean/scripts/hygiene.py:964-975 records
every unwalked subtree with its reason, and preview independently refuses to act on them
(truncated-not-inventoried, :1662-1663). Report-level honesty backed by an enforcement-level
refusal rather than relying on the model to self-report. F2 below is a boundary-condition bug
inside a mechanism that is otherwise the right design.
- Reclaimable-vs-logical byte discipline —
skills/clean/scripts/hygiene.py:660-679 explains why
a qualified size can never be read as reclaimable and why not-walked yields null rather than
0 ("collapsing 'not walked' into zero made a 1.35 GB truncated .cache indistinguishable from an
empty folder"); SKILL.md:203-212 forbids summing logical_size by hand. Observed: D:\spike
reported 78,445,515 logical against 72,824,131 reclaimable, preventing a ~5.6 MB overclaim.
Two smaller ones worth keeping: --execute is correctly unified as flag-not-approval across both
lanes (SKILL.md:39-43) and the first invocation offered no removal lane at all; and the Bash
denial text discloses the absolute interpreter and the authorized --data-root inline
(scripts/destructive_guard.py:1067-1089), so the documented bootstrap worked first try. No
prompt-injection surface was found anywhere in the skill body, reference, policy, or guard sources.
F5 — Session-lifetime deny-by-default Bash belt escapes the scope its own docs claim (High)
What it is. Three places in the plugin's own source state that the frontmatter belt is scoped to
active cleanup:
skills/clean/reference/safety-model.md:217-218 — the belt's lane is enforced "only while the
clean skill is active" (contrasted with the engine gate at :214-215, which correctly says
"whether or not the clean skill is active").
skills/clean/scripts/destructive_guard.py:644-646 — resolve_mode(): belt mode is "deny-by-default
Bash and deletion-spelling PowerShell discipline, tolerable only while the clean skill is the
active work." The author's own stated tolerability precondition.
skills/clean/SKILL.md:362-363 — the belt "adds the deny-by-default Bash and deletion-spelling
PowerShell discipline while cleanup is the active work."
The skills documentation's frontmatter field table says, verbatim (re-fetched from
code.claude.com/docs/en/skills.md during review, not recalled):
hooks — "Hooks that Claude Code registers when the skill is invoked and keeps running for the
rest of the session."
There is no "skill is active" window at the harness level. The scope these three sites describe is not
one the platform provides.
Why the mistake is a natural one, and why that matters for the fix. The same page documents the
opposite lifetime for the other skill-frontmatter fields: an allowed-tools grant "clears when you
send your next message" (Skill content lifecycle), and disallowed-tools likewise — "the restriction
clears when you send your next message." So a skill-frontmatter permission really is scoped to
roughly the active-work window; a skill-frontmatter hook is not. The plugin is already deliberate
about the other frontmatter fields (SKILL.md:354-355 explains why the skill grants no
allowed-tools at all), so carrying the permission-field lifetime across to hooks is an easy slip
with a session-wide consequence — which is why fix 1 below is worth doing even if nothing else is.
Note the shape precisely: nothing here is stated backwards. The engine-gate clause at
safety-model.md:214-215 is correct. The belt clause asserts a narrower lifetime than the harness
gives it.
The spine — a correct principle applied to the wrong surface.
scripts/destructive_guard.py:426-430 shows the author reasoning correctly about exactly this hazard,
for the other surface:
"A plugin-level hook fires on every shell call in every session, so a bare mention of the engine's
filename ... must defer — routing mentions into the belt's fail-closed parser would block ordinary
work session-wide."
That defer-fast discipline was engineered into the gate and withheld from the belt, because the belt
was believed to be time-scoped. This is not an oversight about blast radius; it is a sound principle
applied to the wrong surface.
Blast radius. The belt's Bash lane is deny-by-default with an allowlist of exactly four bundled
engine subcommands plus one argument-free probe (scripts/destructive_guard.py:309;
:1067-1089). Once the skill is invoked, Bash calls on that session's main thread — by any other
plugin, any other skill, and the user — are denied for the remainder of the session unless they match
one of those five shapes.
The realized harm was not inconvenience. Observed in the run: a bare true was denied; a scan with
2>&1 | head -c 700 appended was denied; and a sibling plugin's bundled integrity tool
(plugin-quality's packet-seal.sh record) was denied with disk-hygiene's fail-closed message after
cleanup had already finished. That is cross-plugin denial-of-verification caused by a plugin the
operator invoked for an unrelated purpose — and it is why this audit's own evidence packet went
unsealed and its integrity ungraded.
Reproduction (command held constant, one variable).
| Session |
clean invoked? |
packet-seal.sh on the same host/user |
| Main thread of the run |
yes |
denied, disk-hygiene fail-closed message |
| Fresh session, same script and host |
no |
permitted, record and verify both exit 0 |
Holding the command constant and varying only whether clean was invoked isolates the denial to belt
registration lifetime rather than anything about the script, the interpreter, or the host.
Scope qualifier (verified during review). Bash calls issued from subagent threads in the same
session were observed not to be subject to the belt — echo, ls, and bare engine-filename mentions
were all permitted from a subagent of the session that had invoked clean. This is stated as an
observation, not a mechanism claim. It does not reduce severity: the operator works on the main
thread, and that is where the realized harm occurred. It does confirm the reproduction above was a
valid control rather than a confound.
No escape hatch. Nothing in SKILL.md, safety-model.md, or destructive_guard.py documents a
way to disarm the belt once registered. A grep of the guard for DISK_HYGIENE_* returns exactly one
env var, DISK_HYGIENE_GUARD_WATCHDOG_SECONDS (scripts/destructive_guard.py:1092), which tunes the
watchdog and explicitly cannot disarm it (:1110-1119: an invalid override "falls back to the default
rather than disarming the watchdog"). disk_hygiene_enabled: false makes the guard stricter, not
looser. The only exit is ending the session. That is what turns a scope bug into an unrecoverable one.
Related, and worth folding into the same fix review. The gate's stated intent at :426-430 (bare
mentions must defer, or ordinary work is blocked session-wide) has a documented exception at
:459-461: a path-form word that resolves to the same file as the bundled engine gates regardless of
invocation shape, and :455-457 then hands it to the belt's rules. Verified live on a clean session
with clean never invoked: echo "... hygiene.py" and a relative-path mention both deferred, while
wc -c "<abs-path>/skills/clean/scripts/hygiene.py" — a pure read — was denied with the
deny-by-default message. So a narrow slice of belt deny-by-default is live in every session on
every install. This looks deliberate (it closes the symlink/hard-link alias-evasion class the
docstring describes) and the denial text names the correct workaround ("Use non-Bash read-only tools
for supporting inspection"), so it is raised as a documented-tradeoff note rather than a defect — but
:426-430's stated intent and :459-461's implemented rule are in tension and one of them should be
reworded.
Impact. For the remainder of any session in which clean is invoked, unrelated tooling on the
main thread cannot run, with no documented recovery short of ending the session.
Recommended fix, cheapest first.
- Correct the three documentation sites (
safety-model.md:217-218, resolve_mode() docstring at
destructive_guard.py:644-646, SKILL.md:362-363) to state the real lifetime. Zero behavior
change; removes a false statement of security posture but leaves blast radius exactly as-is. Do
this regardless of what else is chosen.
- Belt-mode Bash returns
ask instead of deny for unrecognized commands. Keeps a human gate,
removes the hard lockout. Honest costs: a permission prompt on every unrelated Bash call for the
rest of the session, and a genuinely weaker fail-closed posture during active cleanup, which is
the window the belt exists for.
- Recommended — real time-scoping via a run-directory sentinel. The skill already creates a
unique run directory under ${CLAUDE_PLUGIN_DATA}/runs/ at step 1 (SKILL.md:115), and the guard
already derives the data root from --plugin-root rather than the environment
(_plugin_data_root_from_root, destructive_guard.py:219) — which matters here, because the
guard's own environment lacks CLAUDE_PLUGIN_DATA (SKILL.md:128). Gate belt-mode
deny-by-default on an open run marker — dropped at step 1, cleared at the step 6 summary — and
fall back to deny when the marker state is unreadable. This delivers precisely the scope the docs
already claim, using state the guard can read today, with no new channel and no weakening during
active cleanup.
- Allowlisting other plugins' cache-resident scripts was considered and is argued against: a
<plugins>/cache/*/**/*.sh allowance is an enormous hole in a deny-by-default belt, since any
cached plugin script can do anything, including deletion. Strictly inferior to (3).
once: true is wrong here — it unregisters the hook after its first run, gutting the belt
mid-cleanup. Named so nobody reaches for it.
F2 — An empty directory can never be a removal candidate at its own scan depth (Medium)
What it is. skills/clean/scripts/hygiene.py:972-975:
elif max_depth is not None and depth >= max_depth:
subtotal = None
walked = False
truncated.append(relative)
The depth test is evaluated before any os.scandir of the child, so it cannot consult whether
children exist. Every directory at depth == max_depth is marked walked=False regardless of
content. metadata() then records logical_size: None and appends the not-walked qualifier
(:685-691); the path lands in truncated_paths (:1037); and SKILL.md §1 instructs never to plan
those for removal, which preview enforces as truncated-not-inventoried via overlaps_truncated
(:1139-1151, :1662-1663).
Net: at a --max-depth N scan, no directory at depth N is ever a candidate, because emptiness is
unobservable at the boundary.
Evidence. Reproduced twice in the run. After D:\spike was emptied, a --max-depth 1 scan still
listed spike in truncated_paths; only --max-depth 2 cleared it. D:\spike and D:\Users each
required a delete-child → rescan-at-depth+1 → delete-parent cycle, two extra scan/verify rounds each.
Second, unreported consequence. The same branch nulls logical_size and so excludes the directory
from reclaimable totals. For an empty directory both effects are wrong in the same direction: it is
vacuously fully inventoried, and its size is genuinely 0, not unknown.
Impact. Two extra approval-gated scan/delete rounds per affected directory, and understated
inventory completeness at every scan boundary.
Recommended fix, cheapest first.
-
At the depth boundary, probe for a first child with a single os.scandir / next(iter(...)) before
marking walked=False and appending to truncated. If there is no child, record walked=True,
logical_size=0, no not-walked qualifier, and do not add to truncated. Safety argument: an
empty directory has no descendants, so nothing uninventoried remains for
truncated-not-inventoried to protect — the preview block loses no coverage and the byte
accounting becomes exact. One cheap scandir per boundary directory; fixes both effects.
Two constraints for whoever applies this. (a) Scope the change to the depth branch at
:972-975 only. The identical subtotal = None; walked = False; truncated.append(relative)
shape also serves the VCS_NAMES branch (:964-967) and the protections branch (:968-971),
where walked=False is correct and deliberate — an empty directory that is protected or is a VCS
root must still land in truncated. Applying the probe generally ships a real regression.
(b) truncated_paths has four downstream consumers, not one: the preview blocker (:1662-1663),
the unverified path (:1691, :1698), apply's contested set (:1797, :1803), and snapshot
passthrough (:2273). Clearing an empty boundary directory from truncated changes behavior at
all four.
-
Failing that, document the constraint in SKILL.md §1 so an operator plans a depth+1 scan
deliberately rather than discovering the cycle empirically.
-
Most ambitious: distinguish not-walked (depth cut) from empty-verified as separate qualifiers,
so downstream consumers can tell the two apart explicitly.
F4 — "Prefer reversible removal" has no long-path branch (Medium)
What it is. skills/clean/SKILL.md:311-314 lists exactly three conditional-reversibility caveats
— "bin size caps, a policy-disabled bin, or a non-NTFS/network volume". A case-insensitive grep of
every .md under skills/clean/ for long path, MAX_PATH, 260, \\?\, and path length
returns no matches. The omission is total, not merely un-emphasized.
Why it matters more than a missing bullet. The three listed caveats all describe a reversible
operation silently becoming permanent. Path length is a different failure: the operation cannot be
attempted at all through the shell APIs behind the Recycle Bin, so the operator must derive an
alternative unaided — and the only alternative is a permanent \\?\ long-path delete. The step
that exists to steer toward reversibility hands the operator, without guidance, the single
irreversible action in the run.
Evidence. D:\Users\KyleSexton — 1,329-character path, 27 levels, 119 MB — was deleted
permanently via Directory.Delete through a \\?\ path, the only permanent deletion in six removal
rounds. The confirmation gate caught what step 6.2 did not warn about: the gate was held firm and a
named confirmation was required before proceeding.
Impact. An operator following step 6.2 literally reaches an irreversible action believing they are
inside the reversible lane, with the tier approval they were given for reversible removals.
Recommended fix, cheapest first.
- Add path length to the 6.2 caveat list, naming the consequence: the fallback is permanent, so it
requires its own explicit approval as an irreversible action rather than inheriting the tier
approval given for reversible removals.
- Have
handoff-verify emit a per-path advisory when a resolved path exceeds the classic limit, so
the warning is deterministic rather than model-recalled.
- Add a
reversibility field to the handoff-verify verdict (reversible / permanent-only) with
its reason, making the risk decision machine-legible and forcing re-approval in step 6.
F6 — Gotchas describe the belt's launch form as exec-form; the shipped frontmatter is shell form (Medium)
What it is. The frontmatter registers the belt in shell form —
skills/clean/SKILL.md:22-24 names "${CLAUDE_PLUGIN_ROOT}"/hooks/run-python-hook.sh ... with
shell: bash, and its own inline comment says so ("Shell form, matching hooks/hooks.json (#2568)").
skills/clean/reference/safety-model.md:196-197 agrees: the belt is "shell form through the same
launcher since 0.17.9" — the version shipped.
skills/clean/SKILL.md:377-390 still says the opposite:
"This skill's frontmatter belt still launches in exec form via python3, resolved on PATH
with no shell ... on a host where python3 does not resolve ... the PreToolUse launch fails, and
Claude Code treats a failed hook launch as a non-blocking error, so the belt does not intercept
there (converting this surface is tracked in #2568)."
The conversion shipped; the bullet did not move with it. The harness premise the bullet rests on is
itself correct — a failed hook launch is a non-blocking error — which is exactly what makes the
stale bullet plausible enough to be believed.
Why it is not cosmetic. The bullet tells an operator that on some hosts the belt silently does not
fire, that "defense-in-depth [is] lost, not preserved", and that /disk-hygiene:setup check should be
consulted. An operator reading current docs would either under-trust a guard that is in fact firing,
or reach for a bypass that no longer exists. It is a false statement of security posture in the
operator-facing document — in the safer direction, but false. This bullet and F5's sit ten lines
apart and are wrong in opposite directions: one under-claims enforcement that exists, the other
over-claims a scope limit that does not.
Mechanism, and why it will recur. The two-surface registration story, the kill-switch history, and
the PowerShell tradeoff are each maintained in full in both SKILL.md Gotchas (:360-411) and
reference/safety-model.md (:174-221, :258+). Hand-maintained duplication with no derivation:
safety-model.md was updated for #2568 and the SKILL.md copy was not. The drift is the predicted
consequence of the duplication, not an isolated slip.
Impact. Operators are told a guard may be silently inert when it is armed, and pointed at a
remedy for a condition that no longer exists.
Recommended fix, cheapest first.
- Correct the
SKILL.md:377-390 bullet to describe shell-form launch. Do it alongside F5 fix 1 —
same file, adjacent lines, one edit.
- Reduce the Gotchas duplication to one-line pointers into
reference/safety-model.md, leaving that
file as the single origin. Also relieves the progressive-disclosure pressure in F7.
- Add a test asserting the frontmatter's declared launch form matches what the docs describe. The
plugin already has precedent for exactly this kind of cross-file assertion —
test_declared_hook_timeouts_match_the_watchdog_ceiling, referenced at
scripts/destructive_guard.py:1095-1100, fails the suite when the two registrations' timeouts
drift.
F1 — Tier rubric has no misplacement axis, and the one ordering rule is undercut by the field list (Low-Medium)
What it is — two parts.
Confirmed: no misplacement axis. The §3 rubric (skills/clean/SKILL.md:193-197) grades
disposability evidence only across all three tiers — provenance strength, owner activity,
work-product resolution. There is no axis for content that is correctly identified but in the wrong
location, and no axis for tidiness value independent of bytes. Confirmed by grep: misplac and
relocat appear nowhere in SKILL.md.
Corrected during review — the ordering rule exists. The audit initially reported that no ranking or
salience instruction existed anywhere in SKILL.md. That is wrong, and the correction changes the
diagnosis. skills/clean/SKILL.md:191 reads:
"Confidence is report priority, not permission:"
So tier-drives-report-priority is stated. The real gap is that it is one compressed clause
introducing a table, easily read as "which findings deserve attention" rather than "presentation
order" — and the report field list two lines later at :199-201 leads with logical bytes ("Report
every finding with path, logical bytes, tier, evidence, ..."), pulling in the opposite direction.
The observed failure was therefore a report that violated an existing instruction, not one that
filled a guidance vacuum.
Two adjacent facts push the same way and are worth naming: §3's closing "Empty directories are not
inherently junk" (:201) argues against flagging exactly the zero-byte-chain case that prompted this
finding, and the Low tier's own text is "Keep unless the human separately reviews and approves
exact paths" — the rubric de-prioritized removal, it did not forbid it.
Evidence. In the run, a zero-byte empty directory chain created by a mis-resolved path was
reported in a way that buried it; the report ranked by bytes and closed with "not worth the risk", and
the user's response was to point at six root entries and ask why they had not been flagged. They had
been — the ordering buried them.
Impact. Correctly-identified findings are presented so that a tidiness-motivated operator cannot
find them, on a volume where byte size is not the operator's concern.
Recommended fix, cheapest first.
- Sharpen
:191 from "Confidence is report priority" to an explicit ordering instruction (report
ordered by tier and evidence strength; byte size never determines presentation order), and reorder
the :199 field list so tier precedes logical bytes. This is a two-line edit that addresses
the actual cause. Note it supersedes the audit's original suggestion to add an ordering rule —
the rule is already there and needs strengthening, not duplicating.
- Add a non-tier annotation (e.g.
tidiness-only, misplaced) that is reportable but confers no
removal permission, so zero-byte residue is legible without inflating its disposability tier.
- Add a genuine second axis (disposability × placement) to the rubric with its own default-outcome
column. Most faithful to the real gap and the largest change to a safety-critical table — the axis
must not become a removal permission by itself.
F3 — No relocation lane, and Move-Item / Rename-Item are unguarded (Low-Medium)
What it is. SKILL.md models exactly two outcomes for any finding: approve exact paths for
deletion (§4-§6), or keep (§3). Grep confirms no move/relocate concept anywhere in the skill body. The
engine's subcommands are scan, preview, handoff-verify, apply
(scripts/destructive_guard.py:309) — no relocation primitive exists, so there is nothing to expose.
Designed-in, not an oversight.
The aggravating factor. skills/clean/reference/safety-model.md:181-189 states that the PowerShell
belt's flagged set is "deletion- and recycle-shaped (plus robocopy mirror/purge/move and .NET
Delete)", so destructive non-deletion spellings — explicitly Move-Item/mv, Rename-Item,
overwriting writers, and volume operations — "reach the tool with no guard verdict at all, in
audit-only mode included". TODO(#387) at :191 tracks the gap. The same passage names the specific
hazard at :188: a Move-Item -Force collaterally destroys whatever it overwrites at the
destination, and the manual handoff's per-path approval does not reach that.
So the relocation that happened in the run — a 76.2 KB file of unique research moved out of D:\tmp
to a working directory, performed outside the skill because the skill has no lane for it — was not
merely unrecorded and unverified. It was the one mutation in the entire run that passed with no
guard coverage of any kind.
Impact. The workflow silently implies completeness it does not have, and the mutation it pushes
outside itself is the one its own guard does not cover.
Recommended fix, cheapest first.
- State the boundary explicitly in §3: relocation is out of scope, the skill offers keep-or-delete
only, and a move must be performed and verified by the operator outside the workflow. Cheapest, and
it stops the workflow from implying completeness.
- Add a
relocate disposition that is report-only — names the path, a suggested destination, and
the rationale in the §3 report, with the engine performing nothing. Preserves the record and the
audit trail at no new mutation surface.
- Close
TODO(#387) so Move-Item/Rename-Item at least reach the same final-human-prompt bar as
deletion spellings. Independent of whether a relocation lane is ever built, and arguably the
higher-value half of this finding.
- Most ambitious: a real relocation lane with snapshot, destination collision check, a
handoff-verify equivalent, and post-move verification. Largest new mutation surface in a plugin
whose entire premise is that mutation is expensive to make safe — recommend against unless demand
is demonstrated.
F7 — Hub is not thin; progressive disclosure inverted (Low)
What it is. skills/clean/SKILL.md is 29,617 bytes / 414 lines, of which the Gotchas section
(:338-414) alone is 76 lines of harness minutiae — while reference/safety-model.md (28,323 bytes,
356 lines) exists and is linked once, at SKILL.md:35. Per the skills documentation's content
lifecycle, a rendered SKILL.md enters the conversation as a single message and stays there for the
rest of the session, so hub weight is paid in full on every invocation including audit-only runs,
while the reference is loaded on demand. The heavy material is on the wrong side of that boundary.
Impact. Every invocation, including read-only audits, pays for ~76 lines of hook-launch and
kill-switch history that the run never needs.
Recommended fix. Move the harness-mechanics Gotchas into reference/safety-model.md — already
their home, and already carrying the same content — leaving load-when pointers behind. This is the
same edit as F6 fix 2; doing it once discharges both.
Not a finding, recorded as an observation. The description is 601 characters
(skills/clean/SKILL.md:2). It is well-constructed for triggering — front-loaded use cases plus
explicit "Skip when" boundaries — and no documented hard cap was found to cite against it.
Limitations of this audit
Stated so nothing above is read as broader coverage than it has.
- No Linux
apply lane evidence — the only lane where the engine itself deletes. This run was
Windows-only; every removal went through the manual handoff, and preview/apply were never
reachable. O_NOFOLLOW descriptor-relative unlink, device/inode identity matching, lsof
fail-closed behavior, and /proc/self/mountinfo parsing are all asserted in the Gotchas and none
were exercised. The most safety-critical code in the plugin has zero behavioral evidence here.
skill-quality:check could not run. check-skill.sh and check-listing-budget.sh (v0.15.9)
both exit 2 with Error: not in a git repo when pointed at a plugin-cache install. The audit's
REQUIRED static gate for a skill component is therefore unsatisfied, and F7's hub-thinness
assessment is a manual walk rather than a tool result. This is a portability observation about
skill-quality, not a disk-hygiene defect — both plugins live in this repo, so please do not
act on it as one.
- Evidence-packet integrity is ungraded.
packet-seal.sh verify returned exit 2 (nothing sealed)
at audit start: the run's evidence file was written before any seal existed, so the seal now
covering it protects forward divergence only. Any pre-audit alteration would be undetectable. This
is a direct downstream effect of F5 — the sealing tool was one of the things the belt denied.
Also unexercised, listed briefly: macOS Trash path; audit-only mode (disk_hygiene_enabled: false
was never in force — the probe reported {"effective": true, "source": "default"}), so the
managed-settings-precedence claim at safety-model.md:207-211 is empirically unverified; policy
overlay layering and --policy replacement semantics; the Stop guard_launch_monitor detector,
which never fired; multi-path handoff-verify staleness (reasoned about, never observed); non-NTFS,
network, and bin-capped volumes — meaning the three reversibility caveats step 6.2 does list are
themselves unexercised, adjacent to F4; --plugin-dir checkout installs; and the belt's PowerShell
lane, which was never observed blocking, so its flagged-spelling coverage is unverified in practice.
TITLE
disk-hygiene:clean: frontmatter Bash belt is session-lifetime, not skill-scoped (+6 findings from a post-use audit)BODY
A post-use audit of the
disk-hygiene:cleanskill turned up seven findings, one of them a falsestatement of security posture with real blast radius: the skill's frontmatter
PreToolUsebelt isdocumented in three places as scoped to "while the clean skill is active", but Claude Code registers
frontmatter hooks for the rest of the session, so its deny-by-default Bash lane stays armed long
after cleanup ends. The remaining six range from a scan boundary-condition bug to stale Gotchas that
contradict the shipped frontmatter. Every finding below was re-verified against
0.17.9source by areviewer who was not in the run.
Provenance. Plugin
disk-hygienev0.17.9, skillclean, marketplace install. Real attended runon a Windows 11 Dev Drive (
D:\, non-OS volume root, 87 GB used / 937 GB free): two invocations(audit-only, then
--execute), 13scancalls, 11 single-pathhandoff-verifycalls, six removalrounds, one permanent long-path deletion. Windows manual-handoff lane only — the engine
applypathnever executed. Findings were produced by a fresh-context auditor reading the packet without the run
transcript, then independently re-verified against source before filing.
Source citations are repo-relative to
plugins/disk-hygiene/.What worked
Recorded first and with citations, because five mechanisms held under real pressure and a reader
skipping to the findings would get a false picture of this component.
skills/clean/SKILL.md:84-111. One surfacerule, one floor, and a per-question table of what an answer must name, plus the rule that a bar
may only require naming something the skill already showed. The run produced three bare "Approved"
replies; two were resolved by asking for the naming, and for the single irreversible action the
gate was held firm until a named confirmation arrived. A yes/no gate could not have drawn that
distinction — the naming bar is what made it expressible.
handoff-verifybefore each deletion —skills/clean/SKILL.md:305-310. The stalenessargument is given in full (the first path's check ages while later paths are still being walked,
and staler after each intervening deletion), and the multi-path form is explicitly reserved for
reporting. Observed: 11 single-path runs for 11 deletions, zero multi-path verdicts used for
execution. The subcommand restates the rule in its own
notefield on every call — in-bandreinforcement that does not depend on the model remembering the skill body.
skills/clean/SKILL.md:55-57. Managed state iscategorically ineligible "even when a native dry-run calls it eligible", which is strong precisely
because it forecloses the most tempting argument. Observed:
D:\packages(29.29 GB across ninepackage managers),
D:\.pnpm-store, andD:\reposwere routed and never planned;D:\Userswaswithheld from a tier while it looked chezmoi-owned and reclassified only on evidence.
truncated_pathscoverage-gap reporting —skills/clean/scripts/hygiene.py:964-975recordsevery unwalked subtree with its reason, and
previewindependently refuses to act on them(
truncated-not-inventoried,:1662-1663). Report-level honesty backed by an enforcement-levelrefusal rather than relying on the model to self-report. F2 below is a boundary-condition bug
inside a mechanism that is otherwise the right design.
skills/clean/scripts/hygiene.py:660-679explains whya qualified size can never be read as reclaimable and why
not-walkedyieldsnullrather than0("collapsing 'not walked' into zero made a 1.35 GB truncated.cacheindistinguishable from anempty folder");
SKILL.md:203-212forbids summinglogical_sizeby hand. Observed:D:\spikereported 78,445,515 logical against 72,824,131 reclaimable, preventing a ~5.6 MB overclaim.
Two smaller ones worth keeping:
--executeis correctly unified as flag-not-approval across bothlanes (
SKILL.md:39-43) and the first invocation offered no removal lane at all; and the Bashdenial text discloses the absolute interpreter and the authorized
--data-rootinline(
scripts/destructive_guard.py:1067-1089), so the documented bootstrap worked first try. Noprompt-injection surface was found anywhere in the skill body, reference, policy, or guard sources.
F5 — Session-lifetime deny-by-default Bash belt escapes the scope its own docs claim (High)
What it is. Three places in the plugin's own source state that the frontmatter belt is scoped to
active cleanup:
skills/clean/reference/safety-model.md:217-218— the belt's lane is enforced "only while theclean skill is active" (contrasted with the engine gate at
:214-215, which correctly says"whether or not the clean skill is active").
skills/clean/scripts/destructive_guard.py:644-646—resolve_mode(): belt mode is "deny-by-defaultBash and deletion-spelling PowerShell discipline, tolerable only while the clean skill is the
active work." The author's own stated tolerability precondition.
skills/clean/SKILL.md:362-363— the belt "adds the deny-by-default Bash and deletion-spellingPowerShell discipline while cleanup is the active work."
The skills documentation's frontmatter field table says, verbatim (re-fetched from
code.claude.com/docs/en/skills.mdduring review, not recalled):There is no "skill is active" window at the harness level. The scope these three sites describe is not
one the platform provides.
Why the mistake is a natural one, and why that matters for the fix. The same page documents the
opposite lifetime for the other skill-frontmatter fields: an
allowed-toolsgrant "clears when yousend your next message" (Skill content lifecycle), and
disallowed-toolslikewise — "the restrictionclears when you send your next message." So a skill-frontmatter permission really is scoped to
roughly the active-work window; a skill-frontmatter hook is not. The plugin is already deliberate
about the other frontmatter fields (
SKILL.md:354-355explains why the skill grants noallowed-toolsat all), so carrying the permission-field lifetime across tohooksis an easy slipwith a session-wide consequence — which is why fix 1 below is worth doing even if nothing else is.
Note the shape precisely: nothing here is stated backwards. The engine-gate clause at
safety-model.md:214-215is correct. The belt clause asserts a narrower lifetime than the harnessgives it.
The spine — a correct principle applied to the wrong surface.
scripts/destructive_guard.py:426-430shows the author reasoning correctly about exactly this hazard,for the other surface:
That defer-fast discipline was engineered into the gate and withheld from the belt, because the belt
was believed to be time-scoped. This is not an oversight about blast radius; it is a sound principle
applied to the wrong surface.
Blast radius. The belt's Bash lane is deny-by-default with an allowlist of exactly four bundled
engine subcommands plus one argument-free probe (
scripts/destructive_guard.py:309;:1067-1089). Once the skill is invoked, Bash calls on that session's main thread — by any otherplugin, any other skill, and the user — are denied for the remainder of the session unless they match
one of those five shapes.
The realized harm was not inconvenience. Observed in the run: a bare
truewas denied; ascanwith2>&1 | head -c 700appended was denied; and a sibling plugin's bundled integrity tool(
plugin-quality'spacket-seal.sh record) was denied with disk-hygiene's fail-closed message aftercleanup had already finished. That is cross-plugin denial-of-verification caused by a plugin the
operator invoked for an unrelated purpose — and it is why this audit's own evidence packet went
unsealed and its integrity ungraded.
Reproduction (command held constant, one variable).
cleaninvoked?packet-seal.shon the same host/userrecordandverifyboth exit 0Holding the command constant and varying only whether
cleanwas invoked isolates the denial to beltregistration lifetime rather than anything about the script, the interpreter, or the host.
Scope qualifier (verified during review). Bash calls issued from subagent threads in the same
session were observed not to be subject to the belt —
echo,ls, and bare engine-filename mentionswere all permitted from a subagent of the session that had invoked
clean. This is stated as anobservation, not a mechanism claim. It does not reduce severity: the operator works on the main
thread, and that is where the realized harm occurred. It does confirm the reproduction above was a
valid control rather than a confound.
No escape hatch. Nothing in
SKILL.md,safety-model.md, ordestructive_guard.pydocuments away to disarm the belt once registered. A grep of the guard for
DISK_HYGIENE_*returns exactly oneenv var,
DISK_HYGIENE_GUARD_WATCHDOG_SECONDS(scripts/destructive_guard.py:1092), which tunes thewatchdog and explicitly cannot disarm it (
:1110-1119: an invalid override "falls back to the defaultrather than disarming the watchdog").
disk_hygiene_enabled: falsemakes the guard stricter, notlooser. The only exit is ending the session. That is what turns a scope bug into an unrecoverable one.
Related, and worth folding into the same fix review. The gate's stated intent at
:426-430(barementions must defer, or ordinary work is blocked session-wide) has a documented exception at
:459-461: a path-form word that resolves to the same file as the bundled engine gates regardless ofinvocation shape, and
:455-457then hands it to the belt's rules. Verified live on a clean sessionwith
cleannever invoked:echo "... hygiene.py"and a relative-path mention both deferred, whilewc -c "<abs-path>/skills/clean/scripts/hygiene.py"— a pure read — was denied with thedeny-by-default message. So a narrow slice of belt deny-by-default is live in every session on
every install. This looks deliberate (it closes the symlink/hard-link alias-evasion class the
docstring describes) and the denial text names the correct workaround ("Use non-Bash read-only tools
for supporting inspection"), so it is raised as a documented-tradeoff note rather than a defect — but
:426-430's stated intent and:459-461's implemented rule are in tension and one of them should bereworded.
Impact. For the remainder of any session in which
cleanis invoked, unrelated tooling on themain thread cannot run, with no documented recovery short of ending the session.
Recommended fix, cheapest first.
safety-model.md:217-218,resolve_mode()docstring atdestructive_guard.py:644-646,SKILL.md:362-363) to state the real lifetime. Zero behaviorchange; removes a false statement of security posture but leaves blast radius exactly as-is. Do
this regardless of what else is chosen.
askinstead ofdenyfor unrecognized commands. Keeps a human gate,removes the hard lockout. Honest costs: a permission prompt on every unrelated Bash call for the
rest of the session, and a genuinely weaker fail-closed posture during active cleanup, which is
the window the belt exists for.
unique run directory under
${CLAUDE_PLUGIN_DATA}/runs/at step 1 (SKILL.md:115), and the guardalready derives the data root from
--plugin-rootrather than the environment(
_plugin_data_root_from_root,destructive_guard.py:219) — which matters here, because theguard's own environment lacks
CLAUDE_PLUGIN_DATA(SKILL.md:128). Gate belt-modedeny-by-default on an open run marker — dropped at step 1, cleared at the step 6 summary — and
fall back to deny when the marker state is unreadable. This delivers precisely the scope the docs
already claim, using state the guard can read today, with no new channel and no weakening during
active cleanup.
<plugins>/cache/*/**/*.shallowance is an enormous hole in a deny-by-default belt, since anycached plugin script can do anything, including deletion. Strictly inferior to (3).
once: trueis wrong here — it unregisters the hook after its first run, gutting the beltmid-cleanup. Named so nobody reaches for it.
F2 — An empty directory can never be a removal candidate at its own scan depth (Medium)
What it is.
skills/clean/scripts/hygiene.py:972-975:The depth test is evaluated before any
os.scandirof the child, so it cannot consult whetherchildren exist. Every directory at
depth == max_depthis markedwalked=Falseregardless ofcontent.
metadata()then recordslogical_size: Noneand appends thenot-walkedqualifier(
:685-691); the path lands intruncated_paths(:1037); andSKILL.md§1 instructs never to planthose for removal, which
previewenforces astruncated-not-inventoriedviaoverlaps_truncated(
:1139-1151,:1662-1663).Net: at a
--max-depth Nscan, no directory at depth N is ever a candidate, because emptiness isunobservable at the boundary.
Evidence. Reproduced twice in the run. After
D:\spikewas emptied, a--max-depth 1scan stilllisted
spikeintruncated_paths; only--max-depth 2cleared it.D:\spikeandD:\Userseachrequired a delete-child → rescan-at-depth+1 → delete-parent cycle, two extra scan/verify rounds each.
Second, unreported consequence. The same branch nulls
logical_sizeand so excludes the directoryfrom reclaimable totals. For an empty directory both effects are wrong in the same direction: it is
vacuously fully inventoried, and its size is genuinely
0, not unknown.Impact. Two extra approval-gated scan/delete rounds per affected directory, and understated
inventory completeness at every scan boundary.
Recommended fix, cheapest first.
At the depth boundary, probe for a first child with a single
os.scandir/next(iter(...))beforemarking
walked=Falseand appending totruncated. If there is no child, recordwalked=True,logical_size=0, nonot-walkedqualifier, and do not add totruncated. Safety argument: anempty directory has no descendants, so nothing uninventoried remains for
truncated-not-inventoriedto protect — the preview block loses no coverage and the byteaccounting becomes exact. One cheap
scandirper boundary directory; fixes both effects.Two constraints for whoever applies this. (a) Scope the change to the depth branch at
:972-975only. The identicalsubtotal = None; walked = False; truncated.append(relative)shape also serves the
VCS_NAMESbranch (:964-967) and theprotectionsbranch (:968-971),where
walked=Falseis correct and deliberate — an empty directory that is protected or is a VCSroot must still land in
truncated. Applying the probe generally ships a real regression.(b)
truncated_pathshas four downstream consumers, not one: the preview blocker (:1662-1663),the
unverifiedpath (:1691,:1698), apply'scontestedset (:1797,:1803), and snapshotpassthrough (
:2273). Clearing an empty boundary directory fromtruncatedchanges behavior atall four.
Failing that, document the constraint in
SKILL.md§1 so an operator plans a depth+1 scandeliberately rather than discovering the cycle empirically.
Most ambitious: distinguish
not-walked(depth cut) fromempty-verifiedas separate qualifiers,so downstream consumers can tell the two apart explicitly.
F4 — "Prefer reversible removal" has no long-path branch (Medium)
What it is.
skills/clean/SKILL.md:311-314lists exactly three conditional-reversibility caveats— "bin size caps, a policy-disabled bin, or a non-NTFS/network volume". A case-insensitive grep of
every
.mdunderskills/clean/forlong path,MAX_PATH,260,\\?\, andpath lengthreturns no matches. The omission is total, not merely un-emphasized.
Why it matters more than a missing bullet. The three listed caveats all describe a reversible
operation silently becoming permanent. Path length is a different failure: the operation cannot be
attempted at all through the shell APIs behind the Recycle Bin, so the operator must derive an
alternative unaided — and the only alternative is a permanent
\\?\long-path delete. The stepthat exists to steer toward reversibility hands the operator, without guidance, the single
irreversible action in the run.
Evidence.
D:\Users\KyleSexton— 1,329-character path, 27 levels, 119 MB — was deletedpermanently via
Directory.Deletethrough a\\?\path, the only permanent deletion in six removalrounds. The confirmation gate caught what step 6.2 did not warn about: the gate was held firm and a
named confirmation was required before proceeding.
Impact. An operator following step 6.2 literally reaches an irreversible action believing they are
inside the reversible lane, with the tier approval they were given for reversible removals.
Recommended fix, cheapest first.
requires its own explicit approval as an irreversible action rather than inheriting the tier
approval given for reversible removals.
handoff-verifyemit a per-path advisory when a resolved path exceeds the classic limit, sothe warning is deterministic rather than model-recalled.
reversibilityfield to thehandoff-verifyverdict (reversible/permanent-only) withits reason, making the risk decision machine-legible and forcing re-approval in step 6.
F6 — Gotchas describe the belt's launch form as exec-form; the shipped frontmatter is shell form (Medium)
What it is. The frontmatter registers the belt in shell form —
skills/clean/SKILL.md:22-24names"${CLAUDE_PLUGIN_ROOT}"/hooks/run-python-hook.sh ...withshell: bash, and its own inline comment says so ("Shell form, matchinghooks/hooks.json(#2568)").skills/clean/reference/safety-model.md:196-197agrees: the belt is "shell form through the samelauncher since 0.17.9" — the version shipped.
skills/clean/SKILL.md:377-390still says the opposite:The conversion shipped; the bullet did not move with it. The harness premise the bullet rests on is
itself correct — a failed hook launch is a non-blocking error — which is exactly what makes the
stale bullet plausible enough to be believed.
Why it is not cosmetic. The bullet tells an operator that on some hosts the belt silently does not
fire, that "defense-in-depth [is] lost, not preserved", and that
/disk-hygiene:setup checkshould beconsulted. An operator reading current docs would either under-trust a guard that is in fact firing,
or reach for a bypass that no longer exists. It is a false statement of security posture in the
operator-facing document — in the safer direction, but false. This bullet and F5's sit ten lines
apart and are wrong in opposite directions: one under-claims enforcement that exists, the other
over-claims a scope limit that does not.
Mechanism, and why it will recur. The two-surface registration story, the kill-switch history, and
the PowerShell tradeoff are each maintained in full in both
SKILL.mdGotchas (:360-411) andreference/safety-model.md(:174-221,:258+). Hand-maintained duplication with no derivation:safety-model.mdwas updated for #2568 and theSKILL.mdcopy was not. The drift is the predictedconsequence of the duplication, not an isolated slip.
Impact. Operators are told a guard may be silently inert when it is armed, and pointed at a
remedy for a condition that no longer exists.
Recommended fix, cheapest first.
SKILL.md:377-390bullet to describe shell-form launch. Do it alongside F5 fix 1 —same file, adjacent lines, one edit.
reference/safety-model.md, leaving thatfile as the single origin. Also relieves the progressive-disclosure pressure in F7.
plugin already has precedent for exactly this kind of cross-file assertion —
test_declared_hook_timeouts_match_the_watchdog_ceiling, referenced atscripts/destructive_guard.py:1095-1100, fails the suite when the two registrations' timeoutsdrift.
F1 — Tier rubric has no misplacement axis, and the one ordering rule is undercut by the field list (Low-Medium)
What it is — two parts.
Confirmed: no misplacement axis. The §3 rubric (
skills/clean/SKILL.md:193-197) gradesdisposability evidence only across all three tiers — provenance strength, owner activity,
work-product resolution. There is no axis for content that is correctly identified but in the wrong
location, and no axis for tidiness value independent of bytes. Confirmed by grep:
misplacandrelocatappear nowhere inSKILL.md.Corrected during review — the ordering rule exists. The audit initially reported that no ranking or
salience instruction existed anywhere in
SKILL.md. That is wrong, and the correction changes thediagnosis.
skills/clean/SKILL.md:191reads:So tier-drives-report-priority is stated. The real gap is that it is one compressed clause
introducing a table, easily read as "which findings deserve attention" rather than "presentation
order" — and the report field list two lines later at
:199-201leads with logical bytes ("Reportevery finding with path, logical bytes, tier, evidence, ..."), pulling in the opposite direction.
The observed failure was therefore a report that violated an existing instruction, not one that
filled a guidance vacuum.
Two adjacent facts push the same way and are worth naming: §3's closing "Empty directories are not
inherently junk" (
:201) argues against flagging exactly the zero-byte-chain case that prompted thisfinding, and the Low tier's own text is "Keep unless the human separately reviews and approves
exact paths" — the rubric de-prioritized removal, it did not forbid it.
Evidence. In the run, a zero-byte empty directory chain created by a mis-resolved path was
reported in a way that buried it; the report ranked by bytes and closed with "not worth the risk", and
the user's response was to point at six root entries and ask why they had not been flagged. They had
been — the ordering buried them.
Impact. Correctly-identified findings are presented so that a tidiness-motivated operator cannot
find them, on a volume where byte size is not the operator's concern.
Recommended fix, cheapest first.
:191from "Confidence is report priority" to an explicit ordering instruction (reportordered by tier and evidence strength; byte size never determines presentation order), and reorder
the
:199field list sotierprecedeslogical bytes. This is a two-line edit that addressesthe actual cause. Note it supersedes the audit's original suggestion to add an ordering rule —
the rule is already there and needs strengthening, not duplicating.
tidiness-only,misplaced) that is reportable but confers noremoval permission, so zero-byte residue is legible without inflating its disposability tier.
column. Most faithful to the real gap and the largest change to a safety-critical table — the axis
must not become a removal permission by itself.
F3 — No relocation lane, and
Move-Item/Rename-Itemare unguarded (Low-Medium)What it is.
SKILL.mdmodels exactly two outcomes for any finding: approve exact paths fordeletion (§4-§6), or keep (§3). Grep confirms no move/relocate concept anywhere in the skill body. The
engine's subcommands are
scan,preview,handoff-verify,apply(
scripts/destructive_guard.py:309) — no relocation primitive exists, so there is nothing to expose.Designed-in, not an oversight.
The aggravating factor.
skills/clean/reference/safety-model.md:181-189states that the PowerShellbelt's flagged set is "deletion- and recycle-shaped (plus
robocopymirror/purge/move and .NETDelete)", so destructive non-deletion spellings — explicitlyMove-Item/mv,Rename-Item,overwriting writers, and volume operations — "reach the tool with no guard verdict at all, in
audit-only mode included".
TODO(#387)at:191tracks the gap. The same passage names the specifichazard at
:188: aMove-Item -Forcecollaterally destroys whatever it overwrites at thedestination, and the manual handoff's per-path approval does not reach that.
So the relocation that happened in the run — a 76.2 KB file of unique research moved out of
D:\tmpto a working directory, performed outside the skill because the skill has no lane for it — was not
merely unrecorded and unverified. It was the one mutation in the entire run that passed with no
guard coverage of any kind.
Impact. The workflow silently implies completeness it does not have, and the mutation it pushes
outside itself is the one its own guard does not cover.
Recommended fix, cheapest first.
only, and a move must be performed and verified by the operator outside the workflow. Cheapest, and
it stops the workflow from implying completeness.
relocatedisposition that is report-only — names the path, a suggested destination, andthe rationale in the §3 report, with the engine performing nothing. Preserves the record and the
audit trail at no new mutation surface.
TODO(#387)soMove-Item/Rename-Itemat least reach the same final-human-prompt bar asdeletion spellings. Independent of whether a relocation lane is ever built, and arguably the
higher-value half of this finding.
handoff-verifyequivalent, and post-move verification. Largest new mutation surface in a pluginwhose entire premise is that mutation is expensive to make safe — recommend against unless demand
is demonstrated.
F7 — Hub is not thin; progressive disclosure inverted (Low)
What it is.
skills/clean/SKILL.mdis 29,617 bytes / 414 lines, of which the Gotchas section(
:338-414) alone is 76 lines of harness minutiae — whilereference/safety-model.md(28,323 bytes,356 lines) exists and is linked once, at
SKILL.md:35. Per the skills documentation's contentlifecycle, a rendered
SKILL.mdenters the conversation as a single message and stays there for therest of the session, so hub weight is paid in full on every invocation including audit-only runs,
while the reference is loaded on demand. The heavy material is on the wrong side of that boundary.
Impact. Every invocation, including read-only audits, pays for ~76 lines of hook-launch and
kill-switch history that the run never needs.
Recommended fix. Move the harness-mechanics Gotchas into
reference/safety-model.md— alreadytheir home, and already carrying the same content — leaving load-when pointers behind. This is the
same edit as F6 fix 2; doing it once discharges both.
Not a finding, recorded as an observation. The
descriptionis 601 characters(
skills/clean/SKILL.md:2). It is well-constructed for triggering — front-loaded use cases plusexplicit "Skip when" boundaries — and no documented hard cap was found to cite against it.
Limitations of this audit
Stated so nothing above is read as broader coverage than it has.
applylane evidence — the only lane where the engine itself deletes. This run wasWindows-only; every removal went through the manual handoff, and
preview/applywere neverreachable.
O_NOFOLLOWdescriptor-relative unlink, device/inode identity matching,lsoffail-closed behavior, and
/proc/self/mountinfoparsing are all asserted in the Gotchas and nonewere exercised. The most safety-critical code in the plugin has zero behavioral evidence here.
skill-quality:checkcould not run.check-skill.shandcheck-listing-budget.sh(v0.15.9)both exit 2 with
Error: not in a git repowhen pointed at a plugin-cache install. The audit'sREQUIRED static gate for a skill component is therefore unsatisfied, and F7's hub-thinness
assessment is a manual walk rather than a tool result. This is a portability observation about
skill-quality, not a disk-hygiene defect — both plugins live in this repo, so please do notact on it as one.
packet-seal.sh verifyreturned exit 2 (nothing sealed)at audit start: the run's evidence file was written before any seal existed, so the seal now
covering it protects forward divergence only. Any pre-audit alteration would be undetectable. This
is a direct downstream effect of F5 — the sealing tool was one of the things the belt denied.
Also unexercised, listed briefly: macOS Trash path; audit-only mode (
disk_hygiene_enabled: falsewas never in force — the probe reported
{"effective": true, "source": "default"}), so themanaged-settings-precedence claim at
safety-model.md:207-211is empirically unverified; policyoverlay layering and
--policyreplacement semantics; theStopguard_launch_monitordetector,which never fired; multi-path
handoff-verifystaleness (reasoned about, never observed); non-NTFS,network, and bin-capped volumes — meaning the three reversibility caveats step 6.2 does list are
themselves unexercised, adjacent to F4;
--plugin-dircheckout installs; and the belt's PowerShelllane, which was never observed blocking, so its flagged-spelling coverage is unverified in practice.