Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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,6 +1,6 @@
{
"name": "disk-hygiene",
"version": "0.43.9",
"version": "0.43.10",
"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
9 changes: 9 additions & 0 deletions plugins/disk-hygiene/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@
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.43.10] - 2026-10-04

### Changed

- **Shorter hook text ([#6225](https://github.com/melodic-software/claude-code-plugins/issues/6225)).** Both Bash denials now name what failed and point at `/disk-hygiene:clean`, whose deny-by-default bullet is the one copy of the allowed shapes; a test keeps that bullet equal to the guard's lists. A belt denial of a bare-Python call still names the hook's interpreter, and with no authorized data root it also names the recovery. The engine-gate denial drops from about 2,500 characters to about 140, the belt denial from about 1,500 to about 160.
- The `/disk-hygiene:clean` guard-values note now carries the engine path and the no-data-root recovery, and drops the channel name and usage line, which the skill body states.
- With no usable Python 3, the denials and the expansion block are one sentence each, the engine-gate notice goes to the user only, and the task-end notice fires once per session, sharing that notice's marker.
- The guard-failure task-end notice and the watchdog `ask` drop their scope disclaimers.

## [0.43.9] - 2026-10-04

### Changed
Expand Down
8 changes: 4 additions & 4 deletions plugins/disk-hygiene/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,8 @@ at preview. Backups remain the recovery boundary for user data.
without `node` no hook launches and no guard is enforced. A `SessionStart` row in shell form
(no `shell` field and no `args`) runs `lib/prerequisites.sh node-notice`, or
`lib/prerequisites.ps1` where there is no `sh`, and needs no node itself. When node is
absent it exits 0 with JSON: `systemMessage` shows the user a warning and `additionalContext`
tells the model, once per session across plugins, and the notice names `/disk-hygiene:check`.
absent it exits 0 with JSON: `systemMessage` shows the user a warning once per session across
plugins, and the notice names `/disk-hygiene:check`.
`disk_hygiene_enabled` set to false silences it. It prints nothing when node is present. Basis: https://code.claude.com/docs/en/hooks, "SessionStart"
(plain stdout reaches Claude only, and exit-2 stderr reaches the user only) and "JSON output"
(`systemMessage` is a warning shown to the user).
Expand Down Expand Up @@ -179,8 +179,8 @@ the call itself, the same way the guard's watchdog answers "could not decide":
| No Python resolves: `/disk-hygiene:clean` expanding | The expansion is blocked with the reason, so the skill and its belt never load |
| No Python resolves: skill-scoped belt, any Bash or PowerShell call | Denied (exit 2), reason on stderr |
| No Python resolves: plugin-level gate, command naming `hygiene.py` (or an empty payload) | Denied (exit 2), reason on stderr |
| No Python resolves: plugin-level gate, any other command its `if` rows let through | **Proceeds unchecked**, with a `systemMessage` and `additionalContext` notice once per session |
| `node` missing or no bash found: every hook | **Proceeds unchecked.** The hook fails to launch, which is non-blocking: the user sees a hook error notice, the guard is not enforced, and the model is not told. With no bash, the notice's first line is the launcher's `exec-bash: <script> did not run, so this hook enforces nothing`. With no `node`, the launcher never starts, so it cannot detect or report the failure there; the shell-form `SessionStart` row warns the user and the model at each session start, and the guard stays unenforced. The Stop detector launches the same way and reports neither |
| No Python resolves: plugin-level gate, any other command its `if` rows let through | **Proceeds unchecked**, with a `systemMessage` notice to the user once per session |
| `node` missing or no bash found: every hook | **Proceeds unchecked.** The hook fails to launch, which is non-blocking: the user sees a hook error notice, the guard is not enforced, and the model is not told. With no bash, the notice's first line is the launcher's `exec-bash: <script> did not run, so this hook enforces nothing`. With no `node`, the launcher never starts, so it cannot detect or report the failure there; the shell-form `SessionStart` row warns the user at session start, and the guard stays unenforced. The Stop detector launches the same way and reports neither |

Of the no-Python rows, the plugin-level gate row is the only fail-open. Those are the commands the guard would
have deferred on had it run; the watchdog asks on them because a missed deadline is transient, but a
Expand Down
21 changes: 13 additions & 8 deletions plugins/disk-hygiene/hooks/run-python-hook.sh
Original file line number Diff line number Diff line change
Expand Up @@ -551,7 +551,7 @@ if [[ -z "$PYTHON" ]]; then
fi
fi

_NO_PYTHON='no Python 3 interpreter resolved on this host (python3, python and py -3 are missing, below the engine floor, or the Windows App Execution Alias stub)'
_NO_PYTHON='no usable Python 3 (python3, python, py -3)'
_NO_PYTHON_FIX='Install Python 3 ahead of WindowsApps on PATH, then run /disk-hygiene:check.'

# Set `_MODE_VALUE` to the guard's `--mode`, read the way the guard's own
Expand Down Expand Up @@ -592,18 +592,19 @@ _no_python_noticed() {
_guard_without_python() {
_guard_mode_value "$@"
if [[ "$_MODE_VALUE" != "engine-gate" ]]; then
printf '%s\n' "disk-hygiene: destructive guard could not run: $_NO_PYTHON, so this call was not checked. The /disk-hygiene:clean belt denies every Bash and PowerShell call it cannot check. $_NO_PYTHON_FIX" >&2
printf '%s\n' "disk-hygiene: denied: $_NO_PYTHON, so the /disk-hygiene:clean belt cannot check any Bash or PowerShell call. $_NO_PYTHON_FIX" >&2
exit 2
fi
((_BUFFERED_STDIN)) || _read_payload
if [[ -z "${_PAYLOAD//[[:space:]]/}" || "${_PAYLOAD,,}" == *hygiene.py* ]]; then
printf '%s\n' "disk-hygiene: destructive guard could not run: $_NO_PYTHON, so this command, which names the disk-hygiene engine (hygiene.py), was not checked against its kill switch and authorized roots, and is denied. $_NO_PYTHON_FIX" >&2
printf '%s\n' "disk-hygiene: denied: $_NO_PYTHON, so this hygiene.py call cannot be checked. $_NO_PYTHON_FIX" >&2
exit 2
fi
# The user hears this once; the model hears it as the deny reason the first
# time it names the engine.
_no_python_noticed && exit 0
local notice="disk-hygiene: destructive guard could not run: $_NO_PYTHON. Commands that name no disk-hygiene engine script proceed unchecked this session; any command naming hygiene.py is denied. Shown once per session. $_NO_PYTHON_FIX"
printf '{"systemMessage":"%s","hookSpecificOutput":{"hookEventName":"PreToolUse","additionalContext":"%s"}}\n' \
"$notice" "$notice"
printf '{"systemMessage":"%s"}\n' \
"disk-hygiene: $_NO_PYTHON; hygiene.py commands are denied this session. $_NO_PYTHON_FIX"
exit 0
}

Expand All @@ -612,11 +613,15 @@ if [[ -z "$PYTHON" ]]; then
guard) _guard_without_python "$@" ;;
context)
printf '{"decision":"block","reason":"%s"}\n' \
"disk-hygiene: /disk-hygiene:clean cannot run here: $_NO_PYTHON. The engine needs one, and without it the skill's guard denies every Bash and PowerShell call. $_NO_PYTHON_FIX"
"disk-hygiene: /disk-hygiene:clean cannot run: $_NO_PYTHON. $_NO_PYTHON_FIX"
;;
monitor)
# Once per session, sharing the guard's notice marker: a user the guard
# already told hears nothing more at task end.
((_BUFFERED_STDIN)) || _read_payload
_no_python_noticed && exit 0
printf '{"systemMessage":"%s"}\n' \
"disk-hygiene: destructive guard could not run this session: $_NO_PYTHON. It reported each call as it happened: calls under the /disk-hygiene:clean belt and commands naming hygiene.py were denied, and other guarded Bash/PowerShell commands proceeded unchecked. $_NO_PYTHON_FIX"
"disk-hygiene: $_NO_PYTHON, so this session's guarded calls were denied or ran unchecked. $_NO_PYTHON_FIX"
;;
*) ;;
esac
Expand Down
27 changes: 18 additions & 9 deletions plugins/disk-hygiene/hooks/run-python-hook.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -481,10 +481,10 @@ done
MONITOR_OUT="$(
PATH="$FAKE_BIN:$PATH" bash "$LAUNCHER" \
"$SCRIPT_DIR/../skills/clean/scripts/guard_launch_monitor.py" \
--data-root /tmp/disk-hygiene-test 2>/dev/null || true
--data-root /tmp/disk-hygiene-test </dev/null 2>/dev/null || true
)"
assert_contains "monitor mode warns when python is unavailable" "systemMessage" "$MONITOR_OUT"
assert_contains "monitor mode names the guard" "destructive guard" "$MONITOR_OUT"
assert_contains "monitor mode names the missing interpreter" "no usable Python 3" "$MONITOR_OUT"
assert_eq "monitor mode's no-interpreter output is valid JSON" "true" \
"$(jq -e 'has("systemMessage")' <<<"$MONITOR_OUT" 2>/dev/null || echo false)"

Expand Down Expand Up @@ -536,7 +536,7 @@ engine_payload='{"session_id":"s-engine","tool_name":"Bash","tool_input":{"comma
nopy_guard "$engine_payload" --marker-root "$NOPY_DIR/data" --launch-marker guard-launch-monitor \
"$GUARD" --mode engine-gate
assert_eq "engine-gate without python denies a command naming the engine" "2" "$NOPY_RC"
assert_contains "the engine deny says the guard could not run" "could not run" "$NOPY_ERR"
assert_contains "the engine deny names the missing interpreter" "no usable Python 3" "$NOPY_ERR"
assert_contains "the engine deny names the remedy" "/disk-hygiene:check" "$NOPY_ERR"
assert_eq "the engine deny prints no allow-shaped stdout" "" "$NOPY_OUT"

Expand All @@ -554,11 +554,10 @@ nopy_guard "$plain_payload" --marker-root "$NOPY_DIR/data" --launch-marker guard
"$GUARD" --mode engine-gate
assert_eq "engine-gate without python lets a marker-free command proceed" "0" "$NOPY_RC"
assert_eq "the marker-free proceed carries a systemMessage" "true" \
"$(jq -e '.systemMessage | test("could not run")' <<<"$NOPY_OUT" 2>/dev/null || echo false)"
assert_eq "the marker-free proceed carries additionalContext" "true" \
"$(jq -e '.hookSpecificOutput.hookEventName == "PreToolUse" and (.hookSpecificOutput.additionalContext | test("could not run"))' <<<"$NOPY_OUT" 2>/dev/null || echo false)"
assert_eq "the marker-free proceed makes no permission decision" "false" \
"$(jq -r '.hookSpecificOutput | has("permissionDecision")' <<<"$NOPY_OUT" 2>/dev/null)"
"$(jq -e '.systemMessage | test("no usable Python 3")' <<<"$NOPY_OUT" 2>/dev/null || echo false)"
# User only: no model copy and no permission decision.
assert_eq "the marker-free proceed carries no hookSpecificOutput" "false" \
"$(jq -r 'has("hookSpecificOutput")' <<<"$NOPY_OUT" 2>/dev/null)"
nopy_guard "$plain_payload" --marker-root "$NOPY_DIR/data" --launch-marker guard-launch-monitor \
"$GUARD" --mode engine-gate
assert_eq "the marker-free notice is once per session: rc" "0" "$NOPY_RC"
Expand All @@ -567,14 +566,24 @@ nopy_guard "${plain_payload/s-plain/s-other}" --marker-root "$NOPY_DIR/data" \
--launch-marker guard-launch-monitor "$GUARD" --mode engine-gate
assert_contains "a new session gets its own notice" "systemMessage" "$NOPY_OUT"

# The task-end notice shares that once-per-session marker.
MONITOR="$SCRIPT_DIR/../skills/clean/scripts/guard_launch_monitor.py"
nopy_guard "$plain_payload" --marker-root "$NOPY_DIR/data" "$MONITOR" --data-root "$NOPY_DIR/data"
assert_eq "the task-end notice stays quiet after the guard's notice" "" "$NOPY_OUT"
stop_payload='{"session_id":"s-stop","hook_event_name":"Stop"}'
nopy_guard "$stop_payload" --marker-root "$NOPY_DIR/data" "$MONITOR" --data-root "$NOPY_DIR/data"
assert_contains "the first task end of a session gets the notice" "no usable Python 3" "$NOPY_OUT"
nopy_guard "$stop_payload" --marker-root "$NOPY_DIR/data" "$MONITOR" --data-root "$NOPY_DIR/data"
assert_eq "a second task end in the same session stays quiet" "" "$NOPY_OUT"

nopy_guard '{"tool_name":"PowerShell","tool_input":{"command":"Get-ChildItem"}}' \
"$GUARD" --mode engine-gate
assert_contains "a payload with no session id re-notices rather than going silent" \
"systemMessage" "$NOPY_OUT"

nopy_guard "$plain_payload" "$GUARD" --plugin-root /p
assert_eq "belt mode without python denies even a marker-free command" "2" "$NOPY_RC"
assert_contains "the belt deny says the guard could not run" "could not run" "$NOPY_ERR"
assert_contains "the belt deny names the missing interpreter" "no usable Python 3" "$NOPY_ERR"
nopy_guard "$plain_payload" "$GUARD" --mode belt
assert_eq "an explicit belt mode without python denies" "2" "$NOPY_RC"
nopy_guard "$plain_payload" "$GUARD" --mode bogus
Expand Down
16 changes: 8 additions & 8 deletions plugins/disk-hygiene/skills/clean/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,13 +115,13 @@ blocked target, 3 when elevation is needed or filesystem state could not be veri
probe need the guard's absolute Python interpreter as `<hook-python>`, and every engine call
needs its authorized `--data-root`; bare `python`/`python3` is rejected because Bash aliases and
functions can replace them. The expansion of this command normally carries a `disk-hygiene guard values`
note naming `hook_python` and `data_root`, resolved by the guard's own code; use both from the
first call. Only when the note is absent, submit the probe once with bare `python`: the guard
denies that read-only call and names its interpreter. Rerun the probe with that interpreter and
note naming `hook_python` (the `<hook-python>`), `engine` (the engine path) and `data_root` (every
`--data-root` value), resolved by the guard's own code; use them from the first call. Only when the
note is absent, submit the probe once with bare `python`: the guard denies that read-only call and names its interpreter. Rerun the probe with that interpreter and
take `data_root` from the probe's `data_root` field. Never submit a scan to learn either value.
A `data_root` of `none` in the note or `null` from the probe means the install layout proved no data root, so the
guard denies every engine call: report the audit as not run, relay the recovery the guard's
denial names, and submit no engine call. If `hook_python` is older than the engine's declared floor (the `MIN_PYTHON` constant
guard denies every engine call: report the audit as not run, relay the recovery the note or a
guard denial names, and submit no engine call. If `hook_python` is older than the engine's declared floor (the `MIN_PYTHON` constant
in `hygiene.py`, the floor's single origin), stop with the declared prerequisite instead of
improvising a different scanner or deletion path.
- Automated, scheduled, remote, unattended, or no-human-in-loop sessions always audit and stop.
Expand Down Expand Up @@ -468,13 +468,13 @@ and what the guard does when no Python resolves → "Hook launch form".
grants none. Consumer permission policy remains authoritative.
- The Bash lane is deny-by-default: only the literal-word bundled scan, inventory, preview,
handoff-verify, catalog, apply, and handoff-apply shapes (plus the argument-free kill-switch probe) pass, using the hook
runtime's own absolute interpreter. The same denial text also admits literal-form read-only
runtime's own absolute interpreter. The guard also admits literal-form read-only
supporting commands whose heads are absolute paths under a trusted system directory: `[`,
`basename`, `dirname`, `du`, `file`, `find`, `ls`, `pwd`, `stat`, `test` (`[` only as a complete
`/usr/bin/[ ... ]` expression; `find` without `-delete`/`-exec`/`-ok`/`-fprint`). Bare names
are denied because exported shell functions shadow them. Engine-gate mode answers those
supporting commands with `ask`; belt mode `allow`s them. The denial text is the source if this
list and the guard diverge. Do supporting inspection with non-Bash read-only tools when the
supporting commands with `ask`; belt mode `allow`s them. This list is the one prose copy: the
guard's denials point here, and a test keeps it equal to the guard's code, which decides. Do supporting inspection with non-Bash read-only tools when the
command is not in that set. Shell expansions, globs, splitting/escape forms, operators,
redirections, aliases, and exported functions fail closed.
- The PowerShell lane is the inverse tradeoff: open for read-only support work, hard-denying engine
Expand Down
11 changes: 5 additions & 6 deletions plugins/disk-hygiene/skills/clean/reference/safety-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -375,10 +375,9 @@ state: only the `--data-root` the guard validated may place it.

**Handing the values over up front.** A plugin `UserPromptExpansion` hook matching
`disk-hygiene:clean$` runs `engine_context.py` through the same launcher, with the same
`--plugin-root` argument, when the command expands. It prints the guard's `_display_python()` and
`resolve_authorized_data_root_channel()` results as `additionalContext`, so the skill needs no denied call to
learn them. The note names the channel that supplied the data root (`--authorized-data-root
argument`, `plugin-cache layout`, or `local-directory marketplace install`). It grants nothing: the guard still judges every call, and a hook that fails prints
`--plugin-root` argument, when the command expands. It prints the guard's interpreter, engine
path and `resolve_authorized_data_root()` result as `additionalContext`, so the skill needs no
denied call to learn them, and the guard's denials need not repeat them. It grants nothing: the guard still judges every call, and a hook that fails prints
nothing and leaves the skill on the kill-switch probe, whose `hook_python` and `data_root` fields
come from the guard's `launch_disclosure` for the probe's install root. The note and the skill
belt now share the same three channels, so a `--plugin-dir` session with no marketplace proof
Expand Down Expand Up @@ -468,7 +467,7 @@ from that install and derives the marketplace's canonical data root. `CLAUDE_CON
reopen the env-injection hole, so a relocated config derives nothing from its relocated files (see the
account-home note above). Both fail closed (every engine invocation denied) while the
destructive-action guard itself stays fully active. This is a deliberate safe-over-convenient
tradeoff, not a security gap. The belt's denial names one recovery: run this plugin from a
tradeoff, not a security gap. The guard-values note and an engine-call denial name one recovery: run this plugin from a
marketplace install, or register the checkout as a local-directory marketplace
(`claude plugin marketplace add <checkout>`) so a `--plugin-dir` session inside it derives that
marketplace's data root. Setting `CLAUDE_PLUGIN_DATA` in the launch shell is not a recovery: a
Expand Down Expand Up @@ -670,7 +669,7 @@ guard on the call itself (#3861), mirroring the watchdog's "could not decide" ru
every call (exit 2), the engine gate denies any payload naming `hygiene.py` or carrying nothing, and
the `/disk-hygiene:clean` expansion is blocked so the belt never loads. The engine gate's
marker-free commands differ from the watchdog: they proceed unchecked with a once-per-session
`systemMessage` and `additionalContext` notice rather than an `ask`. The Stop detector is kept as the end-of-turn
`systemMessage` notice to the user rather than an `ask`. The Stop detector is kept as the end-of-turn
backstop. Verified 2026-09-28 against Claude Code 2.1.280 at
<https://code.claude.com/docs/en/hooks> (exit 2 blocks a PreToolUse call whatever stdout carries;
exit 0 with no `permissionDecision` proceeds through the normal permission flow; a hook `ask` forces
Expand Down
Loading
Loading