Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion plugins/disk-hygiene/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "disk-hygiene",
"version": "0.9.4",
"version": "0.9.5",
"description": "Context-aware disk hygiene for arbitrary directory trees: inventories orphaned and temporary artifacts, classifies evidence into review tiers, and offers exact-path cleanup only after a fresh safety preview and explicit per-tier approval. The target is read-only by default; OS-managed paths, links and mount points, VCS-tracked content, changed entries, and live-handle uncertainty fail closed.",
"author": {
"name": "Melodic Software",
Expand Down
34 changes: 34 additions & 0 deletions plugins/disk-hygiene/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,40 @@
All notable changes to the `disk-hygiene` plugin are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning.

## [0.9.5]

### Fixed

- **A silent `destructive_guard.py` launch/runtime failure is now surfaced instead of looking
identical to an approval (#1416).** A repo-operator investigation of the original #1416 report
found both cited launch-refusal root causes already fixed and merged (#1242/0.9.0 here,
repo-hygiene's own guard by #1006); what remained live was that "the guard denied nothing because
it approved" and "the guard denied nothing because it never ran, or ran and died" were
indistinguishable from outside the harness. A new detector,
`skills/clean/scripts/guard_launch_monitor.py`, registers as a second, independent hook in
`hooks/hooks.json` — on `Stop`, not `PreToolUse`/`PostToolUse`, to avoid repeating the per-tool-call
cost class documented in
`docs/adr/0004-rightsize-instruction-surfaces-by-incumbent-first-arbitration.md`'s D-12 — and
scans the session transcript's tail for `hook_non_blocking_error` records naming
`destructive_guard.py`. On a match it emits one `systemMessage` per session (never a block, never a
`permissionDecision`) naming the guard, the failure count, and the most recent failure's exit code,
duration, and truncated stderr. It is a separate, stdlib-only process — deliberately not wired
through the guard's own code, since a guard that cannot launch cannot report that it did not
launch — and fails silently closed on any read/parse error so it can never itself become the
reason a turn is blocked. It covers only `destructive_guard.py`'s own command string: repo-hygiene's
guard is out of scope (verified working separately), there is no retroactive scan of prior
sessions, and — because both hooks are wired with the same literal `python3` command — the
interpreter-resolution fail-open documented in the README (the WindowsApps alias stub, or a
missing/broken `python3`) takes the detector down with the guard, so that one vector stays
unreported until the detector gets a launcher independent of the guarded interpreter (#1504). The
bounded tail read discards its first line only when the retained window actually starts mid-record:
when `size - _MAX_TAIL_BYTES` lands exactly on a record's first byte, an unconditional discard threw
away a whole record — which can be the session's only guard failure, silencing the very report the
detector exists to make. The once-per-session marker is written only after the warning has actually
left the process (`print` then `flush`, then mark): marking first meant a closed pipe or a kill
between the two silenced every later `Stop` in the session while the broad never-fail-loudly handler
exited quietly — reinstating the silence the detector exists to break.

## [0.9.4]

### Fixed
Expand Down
12 changes: 12 additions & 0 deletions plugins/disk-hygiene/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,18 @@ at preview. Backups remain the recovery boundary for user data.
commands are denied after a clean run ends, start a new session and see that issue. PreToolUse
hooks also fire inside subagents, so fanned-out workers run under the same guards. The plugin
never downloads a runtime.
- **A silent engine-gate launch/runtime failure is now surfaced (since 0.9.5, #1416).** A `Stop`-event
detector (`skills/clean/scripts/guard_launch_monitor.py`, a second hook entry in `hooks/hooks.json`,
independent of the engine-gate guard itself) scans the session transcript for
`hook_non_blocking_error` records naming the engine gate's own command string and warns once per
session with the failure count and the most recent failure's exit code, duration, and stderr — so a
guard that never ran or died mid-run no longer looks identical to a guard that ran and approved.
This covers only the `destructive_guard.py` command string in the current session's transcript: it
does not cover repo-hygiene's own guard (a separate plugin, verified working independently), and it
never retroactively scans a prior session's transcript. It is also wired with the same literal
`python3` command as the guard it watches, so the interpreter-resolution failure below — the
WindowsApps alias stub, or a missing/broken `python3` — takes the detector down with the guard and
goes unreported (#1504).
- Git is optional for ordinary trees. If a target contains or sits inside a Git worktree, Git becomes
required so tracked content can be proven safe; otherwise cleanup for that subtree is blocked.
- Windows has the full **audit** lane (Python 3.11's `lstat` reparse metadata plus Win32 APIs
Expand Down
17 changes: 17 additions & 0 deletions plugins/disk-hygiene/hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,23 @@
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "python3",
Comment thread
kyle-sexton marked this conversation as resolved.
"args": [
"${CLAUDE_PLUGIN_ROOT}/skills/clean/scripts/guard_launch_monitor.py",
"--data-root",
"${CLAUDE_PLUGIN_DATA}"
],
"timeout": 20,
"statusMessage": "Checking for silent destructive-guard failures..."
}
]
}
]
}
}
25 changes: 25 additions & 0 deletions plugins/disk-hygiene/skills/clean/reference/safety-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,31 @@ Even when the switch resolves enabled, the PowerShell lane is a raised bar, not
mutation spelling passes it, so the engine's own containment, revalidation, and platform gates remain the
deletion authority.

**Guard launch/runtime failures are now surfaced, not silently indistinguishable from approval (since
0.9.5, #1416).** A `PreToolUse` hook that fails to launch, or launches and then exits non-zero, denies
nothing — Claude Code treats a non-blocking hook result as approval, so "the guard denied nothing because
it approved" and "the guard denied nothing because it never ran, or ran and silently died" looked
identical from outside the harness. `skills/clean/scripts/guard_launch_monitor.py` closes that gap with a
second, independent hook registered on `Stop` in `hooks/hooks.json` (deliberately not `PreToolUse`, to
avoid taxing every guarded tool call the way `docs/adr/0004-...`'s D-12 did): it scans the session
transcript's tail for `hook_non_blocking_error` records whose command string names
`destructive_guard.py`, and if it finds any, emits a `systemMessage` — never a block, never a
`permissionDecision` — naming the guard, the total failure count, and the most recent failure's exit
code, duration, and truncated stderr, at most once per session. It is a separate, stdlib-only process
that imports nothing from the guard: a guard that cannot launch cannot report that it did not launch, so
the detector cannot depend on the guard's own code path, and it fails silently closed (exit 0, no output)
on any transcript-read or parse error so it can never itself become the reason a turn is blocked. What
this does **not** cover: repo-hygiene ships its own, structurally different guard, verified working
independently and out of scope here; the detector's command-substring filter matches only
`destructive_guard.py` invocations, so a renamed or unrelated guard script is invisible to it the same
way it is invisible to the engine gate's own coverage marker (see above); it never retroactively
scans a prior session's transcript — only the transcript named by the current `Stop` event's own
`transcript_path`; and it is wired with the same literal `python3` command as the guard it watches,
so the interpreter-resolution fail-open the plugin README documents (the WindowsApps
`python3.exe` alias stub, or a missing/broken `python3`) takes the detector down with the guard and
leaves that one vector unreported. Closing that requires a launcher whose availability does not
depend on the same lookup, which is tracked separately (#1504).

A depth-limited scan records every directory it declined to enter in `truncated_paths`. Truncated
directories have no captured descendant set, so the preview blocks them (and anything beneath them)
as `truncated-not-inventoried`; they are coverage gaps, never candidates.
Expand Down
Loading