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.23.11",
"version": "0.23.12",
"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 without the complete checkout evidence bundle, changed entries, and live-handle uncertainty fail closed.",
"author": {
"name": "Melodic Software",
Expand Down
8 changes: 8 additions & 0 deletions plugins/disk-hygiene/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@
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.23.12]

### Changed

- **The `Stop` guard-launch monitor no longer starts Python in a session that launched no guard.** The engine-gate rows are `if`-gated on the engine's file name, so most sessions never run `destructive_guard.py` at all, yet the monitor started a whole interpreter every turn to discover that from the transcript. `Stop` rows accept neither `matcher` nor `if`, so the gate lives in `hooks/run-python-hook.sh`, which grows three optional leading flags: `--marker-root <dir>`, `--launch-marker <subdir>` (write `<root>/<subdir>/<session>.launched` before exec'ing Python) and `--skip-unless-marker <subdir>` (exit 0 without exec'ing when a candidate marker directory exists and none of them holds that file). Process creations per `Stop` on Windows, measured with a job-object census at n=5: 5 before, 3 with no marker, and still 5 with one present, against a 1-creation harness floor; wall clock 271 ms to 120 ms on the skipped path. The engine-gate row pays nothing for the marker after the first launch in a plugin data root, which spends one `mkdir`.
- Marker semantics: the marker is per session and is never removed, so a data root accumulates one empty file per session that launched a guard, and no retention sweep collects them; a session whose guard rows never fired is skipped by design; the marker is written before the interpreter is resolved, so a guard that launches and dies, the failure the monitor exists to report, still leaves it. With a marker present the monitor's `systemMessage` and its `guard-decisions` record are byte-identical to before. A skipped turn emits no telemetry envelope at all, where it previously emitted an `ok` one.
- The `Stop` gate fails open on the half it can detect: it skips only when at least one candidate marker directory exists and holds no marker for the session, so a launch whose `mkdir` failed for both roots leaves nothing and every later `Stop` runs the monitor as it did before the flags. The cost is that the skip is inert in a plugin data root where no guard has ever launched, until the first launch spends its `mkdir`: 5 creations with no candidate directory, 3 with an empty one, 5 with a marker present. The residual it cannot detect: a candidate directory that exists while the marker file itself could not be written (a full disk, a permission denial on the file alone) still silences the monitor for that session, because that file is the only channel between the launch row and the `Stop` row.

## [0.23.11]

### Changed
Expand Down
10 changes: 5 additions & 5 deletions plugins/disk-hygiene/hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"hooks": [
{
"type": "command",
"command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/run-python-hook.sh \"${CLAUDE_PLUGIN_ROOT}\"/skills/clean/scripts/destructive_guard.py --mode engine-gate --plugin-root \"${CLAUDE_PLUGIN_ROOT}\" --authorized-data-root \"${CLAUDE_PLUGIN_DATA}\"",
"command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/run-python-hook.sh --marker-root \"${CLAUDE_PLUGIN_DATA}\" --launch-marker guard-launch-monitor \"${CLAUDE_PLUGIN_ROOT}\"/skills/clean/scripts/destructive_guard.py --mode engine-gate --plugin-root \"${CLAUDE_PLUGIN_ROOT}\" --authorized-data-root \"${CLAUDE_PLUGIN_DATA}\"",
"if": "Bash(*hygiene.py*)",
"shell": "bash",
"timeout": 60,
Expand All @@ -20,23 +20,23 @@
"hooks": [
{
"type": "command",
"command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/run-python-hook.sh \"${CLAUDE_PLUGIN_ROOT}\"/skills/clean/scripts/destructive_guard.py --mode engine-gate --plugin-root \"${CLAUDE_PLUGIN_ROOT}\" --authorized-data-root \"${CLAUDE_PLUGIN_DATA}\"",
"command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/run-python-hook.sh --marker-root \"${CLAUDE_PLUGIN_DATA}\" --launch-marker guard-launch-monitor \"${CLAUDE_PLUGIN_ROOT}\"/skills/clean/scripts/destructive_guard.py --mode engine-gate --plugin-root \"${CLAUDE_PLUGIN_ROOT}\" --authorized-data-root \"${CLAUDE_PLUGIN_DATA}\"",
"if": "PowerShell(*hygiene.py*)",
"shell": "bash",
"timeout": 60,
"statusMessage": "Checking the disk-hygiene delete against its authorized roots..."
},
{
"type": "command",
"command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/run-python-hook.sh \"${CLAUDE_PLUGIN_ROOT}\"/skills/clean/scripts/destructive_guard.py --mode engine-gate --plugin-root \"${CLAUDE_PLUGIN_ROOT}\" --authorized-data-root \"${CLAUDE_PLUGIN_DATA}\"",
"command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/run-python-hook.sh --marker-root \"${CLAUDE_PLUGIN_DATA}\" --launch-marker guard-launch-monitor \"${CLAUDE_PLUGIN_ROOT}\"/skills/clean/scripts/destructive_guard.py --mode engine-gate --plugin-root \"${CLAUDE_PLUGIN_ROOT}\" --authorized-data-root \"${CLAUDE_PLUGIN_DATA}\"",
"if": "PowerShell(*python*$*)",
"shell": "bash",
"timeout": 60,
"statusMessage": "Checking the disk-hygiene delete against its authorized roots..."
},
{
"type": "command",
"command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/run-python-hook.sh \"${CLAUDE_PLUGIN_ROOT}\"/skills/clean/scripts/destructive_guard.py --mode engine-gate --plugin-root \"${CLAUDE_PLUGIN_ROOT}\" --authorized-data-root \"${CLAUDE_PLUGIN_DATA}\"",
"command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/run-python-hook.sh --marker-root \"${CLAUDE_PLUGIN_DATA}\" --launch-marker guard-launch-monitor \"${CLAUDE_PLUGIN_ROOT}\"/skills/clean/scripts/destructive_guard.py --mode engine-gate --plugin-root \"${CLAUDE_PLUGIN_ROOT}\" --authorized-data-root \"${CLAUDE_PLUGIN_DATA}\"",
"if": "PowerShell(*& $*)",
"shell": "bash",
"timeout": 60,
Expand All @@ -50,7 +50,7 @@
"hooks": [
{
"type": "command",
"command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/run-python-hook.sh \"${CLAUDE_PLUGIN_ROOT}\"/skills/clean/scripts/guard_launch_monitor.py --data-root \"${CLAUDE_PLUGIN_DATA}\"",
"command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/run-python-hook.sh --marker-root \"${CLAUDE_PLUGIN_DATA}\" --skip-unless-marker guard-launch-monitor \"${CLAUDE_PLUGIN_ROOT}\"/skills/clean/scripts/guard_launch_monitor.py --data-root \"${CLAUDE_PLUGIN_DATA}\"",
"shell": "bash",
"timeout": 20,
"statusMessage": "Checking for silent destructive-guard failures..."
Expand Down
176 changes: 176 additions & 0 deletions plugins/disk-hygiene/hooks/run-python-hook.sh
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,93 @@ except OSError:
raise SystemExit(0 if sys.version_info >= floor else 1)
'

# --- optional per-session launch marker ------------------------------------
#
# Three OPTIONAL leading flags, consumed here and never forwarded to Python:
#
# --marker-root <dir> root the marker tree lives under, spelled
# `"${CLAUDE_PLUGIN_DATA}"` by every caller;
# --launch-marker <subdir> write `<root>/<subdir>/<session>.launched`
# BEFORE exec'ing the target;
# --skip-unless-marker <subdir> exit 0 without exec'ing anything when at
# least one candidate directory exists and
# none of them holds that file.
#
# Together they let a `Stop` hook cost nothing in a session where the hook it
# watches never ran. `guard_launch_monitor.py` reports failures of the
# `PreToolUse` guard, and the guard rows are `if`-gated on the engine's file
# name, so most sessions never launch the guard at all — yet the monitor still
# started a whole Python on every turn to discover that. `Stop` rows accept
# neither `matcher` nor `if`, so the only place that gate can live is here.
#
# The root is passed EXPLICITLY rather than read from `CLAUDE_PLUGIN_DATA` in
# the environment: a hook subprocess can inherit that variable naming a
# DIFFERENT plugin's data directory, and a writer and a reader that disagree
# about the root skip silently, which is the missed-detection failure this
# monitor exists to prevent. An empty value, or an unsubstituted literal
# `${CLAUDE_PLUGIN_DATA}`, is treated as absent (the placeholder idiom
# `guard_launch_monitor.py` already uses) and the tmp fallback carries the
# marker alone.
#
# Failure modes, stated rather than implied:
# * a marker is per session and is never removed. Under the tmp fallback the
# OS clears it; under `--marker-root` it is one empty file per session that
# launched a guard, and neither the clean engine nor `lib/guard_decision_log.py`
# runs a retention sweep over the plugin data root that could collect them.
# * a session whose guard rows never fired is skipped BY DESIGN: there is no
# guard invocation for the monitor to have found a failure of.
# * a hook that failed before this script ran leaves no marker and is not this
# monitor's to detect — the detector reads the transcript for the guard's
# own command string, which such a failure still records.
# * the marker is written before the interpreter is even resolved, so the
# failure the monitor DOES exist to catch — the guard launching and dying —
# still leaves the marker that keeps the monitor running.
# * the session id is recovered with a regex over the raw payload rather than
# a JSON parse, because a parse is the Python this gate exists to avoid.
# The match is anchored to the payload's opening key, which nothing further
# in the payload can reach; only a payload that does not open with
# `session_id` falls back to an unanchored match, where a NESTED
# `session_id` key could win instead and key the marker wrongly. Both rows
# parse identically, so that corner is a missed detection, never a false
# alarm.
# * a misparse fails SAFE: no session id means write nothing and skip
# nothing, which is this launcher's behavior before these flags existed.
# * a launch that could create NEITHER candidate directory leaves no marker
# at all, and a Stop that read only the marker file would then silence the
# monitor for the whole session: a silent failure in the one detector that
# exists to catch silent failures. So the skip is gated on a candidate
# DIRECTORY existing. With none, the Stop row falls through and runs the
# monitor, which is the behavior before these flags existed. The cost of
# that fail-open is that the skip is inert in a plugin data root where no
# guard has ever launched: every Stop pays the old price until the first
# guard launch spends its one `mkdir`.
# * RESIDUAL, and undetectable across processes: a candidate directory that
# exists while the marker FILE could not be written (a full disk, a
# permission denial on the file alone) still degrades to silence for that
# session. The file that failed is the only channel between the launch row
# and the Stop row, so the Stop row cannot tell that case apart from a
# session whose guard rows never fired.
MARKER_ROOT=""
LAUNCH_MARKER_SUBDIR=""
SKIP_MARKER_SUBDIR=""
while (($#)); do
case "$1" in
--marker-root)
MARKER_ROOT="${2:-}"
shift 2 || break
;;
--launch-marker)
LAUNCH_MARKER_SUBDIR="${2:-}"
shift 2 || break
;;
--skip-unless-marker)
SKIP_MARKER_SUBDIR="${2:-}"
shift 2 || break
;;
*) break ;;
esac
done

SCRIPT="${1:-}"
shift || true

Expand All @@ -90,6 +177,86 @@ case "$SCRIPT" in
*) ;;
esac

# shellcheck disable=SC2016 # the literal placeholder, deliberately unexpanded
_DATA_ROOT_PLACEHOLDER='${CLAUDE_PLUGIN_DATA}'
_MARKER_PATHS=()
_PAYLOAD=""
_SESSION_ID=""
_BUFFERED_STDIN=0

# Buffer the whole hook payload into a variable and recover its session id.
# `read` is a builtin, so buffering costs no process; the payload is handed to
# Python on a here-string at `exec` time, leaving its stdin unchanged.
_read_payload() {
_BUFFERED_STDIN=1
IFS= read -r -d '' _PAYLOAD || true
local field='"session_id"[[:space:]]*:[[:space:]]*"([^"\\]*)"'
# Anchored first. The hooks reference shows `session_id` as the payload's
# opening key for every event but documents no ordering guarantee, and the
# payload nests objects of its own (`tool_input`) that could carry the same
# key name. An anchored match cannot be reached by either; the unanchored
# fallback runs only for a payload that did NOT open with the field, and keeps
# a reordered payload from turning this gate into a silent no-op.
local anchored="^[[:space:]]*[{][[:space:]]*$field"
if [[ "$_PAYLOAD" =~ $anchored ]] || [[ "$_PAYLOAD" =~ $field ]]; then
_SESSION_ID="${BASH_REMATCH[1]}"
fi
}

# Fill `_MARKER_PATHS` with the candidates for one subdir, most-preferred
# first, mirroring `guard_launch_monitor.py`'s own `_marker_path_candidates`:
# the data root when one resolved, then a tmp fallback whose directory name is
# built from the subdir, so a `.launched` marker sits beside the `.warned` one
# the monitor writes. Only bash reads and writes `.launched`, so the tmp
# directory need not be the one Python's `tempfile.gettempdir()` picks; what
# matters is that both flags resolve it identically, which they do.
_marker_candidates() {
local subdir="$1" session="$2"
local safe="${session//[^a-zA-Z0-9_-]/_}"
_MARKER_PATHS=()
[[ -n "$safe" ]] || return 1
if [[ -n "$MARKER_ROOT" && "$MARKER_ROOT" != "$_DATA_ROOT_PLACEHOLDER" ]]; then
_MARKER_PATHS+=("$MARKER_ROOT/$subdir/$safe.launched")
fi
_MARKER_PATHS+=("${TMPDIR:-/tmp}/disk-hygiene-$subdir/$safe.launched")
}

if [[ -n "$SKIP_MARKER_SUBDIR" ]]; then
_read_payload
if [[ -n "$_SESSION_ID" ]] && _marker_candidates "$SKIP_MARKER_SUBDIR" "$_SESSION_ID"; then
_marker_found=0
# Skipping requires POSITIVE evidence that the launch side got as far as a
# marker tree. No candidate directory at all is the shape a launch whose
# `mkdir` failed for every root leaves behind, and silencing the monitor on
# it would be a silent failure in the detector that exists to catch silent
# failures. Both tests are builtins, so the skip path still spawns nothing.
_marker_dir_seen=0
for _marker_path in "${_MARKER_PATHS[@]}"; do
[[ -d "${_marker_path%/*}" ]] && _marker_dir_seen=1
if [[ -f "$_marker_path" ]]; then
_marker_found=1
break
fi
done
if ((_marker_dir_seen && !_marker_found)); then
exit 0
fi
fi
fi

if [[ -n "$LAUNCH_MARKER_SUBDIR" ]]; then
((_BUFFERED_STDIN)) || _read_payload
if [[ -n "$_SESSION_ID" ]] && _marker_candidates "$LAUNCH_MARKER_SUBDIR" "$_SESSION_ID"; then
for _marker_path in "${_MARKER_PATHS[@]}"; do
_marker_dir="${_marker_path%/*}"
# `mkdir` is the one spawn on this path and `[[ -d ]]` is a builtin, so it
# is paid once per root rather than once per launch.
[[ -d "$_marker_dir" ]] || mkdir -p "$_marker_dir" 2>/dev/null || continue
: >"$_marker_path" 2>/dev/null && break
done
Comment thread
kyle-sexton marked this conversation as resolved.
fi
fi
Comment thread
kyle-sexton marked this conversation as resolved.

# Portable WindowsApps path-component check (case-insensitive).
_under_windowsapps() {
[[ "${1,,}" == *windowsapps* ]]
Expand Down Expand Up @@ -315,4 +482,13 @@ if [[ -z "$PYTHON" ]]; then
exit 0
fi

# A buffered payload is replayed on a here-string, which bash serves from a pipe
# or a temp file without creating a process. Python reads the same bytes it
# would have read from the inherited stdin, plus the newline `<<<` appends —
# both consumers here (`json.load(sys.stdin)` in the guard, `sys.stdin.read()`
# then `json.loads` in the monitor) ignore trailing whitespace.
if ((_BUFFERED_STDIN)); then
exec "$PYTHON" "$SCRIPT" "$@" <<<"$_PAYLOAD"
fi

exec "$PYTHON" "$SCRIPT" "$@"
Loading