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.17.7",
"version": "0.17.8",
"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
37 changes: 37 additions & 0 deletions plugins/disk-hygiene/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,43 @@
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.17.8]

### Fixed

- **Wired hooks launch in shell form, restoring the destructive guard on Windows (#1416).**
`0.17.6` moved both `hooks/hooks.json` registrations onto `"command": "bash"` + `args` to
resolve a real Python 3 interpreter (#1504) — and in doing so reintroduced the exact launch
failure #1006 had already fixed for the skill-frontmatter hook. Exec form (`args` present) is
a bare `PATH` lookup, and on Windows `bash` resolves to the WSL relay `System32\bash.exe`
before Git Bash: `execvpe(/bin/bash) failed: No such file or directory`. A hook that fails to
launch is a **non-blocking** error, so `destructive_guard.py` never ran and the PreToolUse
gate silently enforced nothing on every such host. Both registrations now name
`run-python-hook.sh` directly with `"shell": "bash"` and no `args`, which Claude Code routes
through Git Bash instead of a `PATH` lookup. Every `${CLAUDE_PLUGIN_ROOT}` /
`${CLAUDE_PLUGIN_DATA}` placeholder is double-quoted, so the argv is byte-identical to the
exec-form vector across paths containing spaces. The #1504 Python-resolution behaviour is
unchanged — only the launch mechanism moves. `hooks/run-python-hook.test.sh` previously
asserted `.command == "bash"`, encoding the defect as the contract; it now asserts the
portability property (launcher named in `command`, no `args`, `shell: bash`, every
placeholder quoted).
- **Security records now assess the shell-form launch instead of asserting the old exec form.**
The README trust-surface record and `skills/clean/reference/safety-model.md` still bounded the
plugin-level hook by "exec form (no shell)" — a safety claim the same change disproved, so the
plugin's own security assessment reasoned from a false premise. Both now state what shell form
does and does not guarantee: the command string is a fixed literal in the plugin's own
`hooks.json` with no model-, repo-, or session-supplied interpolation, whose only substituted
values are Claude Code's own double-quoted `${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PLUGIN_DATA}`
placeholders — verified byte-identical to the exec-form argv for roots containing spaces and
backslashes — while noting that those placeholders are substituted textually before bash parses
the result, so the quoting bounds whitespace and backslashes rather than every shell
metacharacter. The invariant is now maintained by `hooks/run-python-hook.test.sh` and the
form-agnostic `test_hygiene.py` hook helpers rather than being structural (repo-wide gate: #2569).
`skills/clean/SKILL.md` split its single launch bullet per surface — the wired gate resolves
Python through `run-python-hook.sh`, while the skill-scoped belt is the one still exec-form on a
bare `python3` (#2568) — and `safety-model.md` dropped a stale claim that the `Stop` detector
shares the guard's `python3` lookup and leaves that vector unreported, which #1504 already closed.

## [0.17.7]

### Changed
Expand Down
51 changes: 38 additions & 13 deletions plugins/disk-hygiene/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,16 @@ at preview. Backups remain the recovery boundary for user data.
- Python 3.11+ available on `PATH` is required for scanning, validation, the skill-scoped guard, and
cleanup (the floor's single origin is the `MIN_PYTHON` constant in
`skills/clean/scripts/hygiene.py`; `/disk-hygiene:setup check` derives the enforced value from
there, so treat the number printed here as a convenience copy). Claude Code launches the guard in
shell-free exec form; guarded engine calls must use the same absolute interpreter reported by that
guard, so Bash aliases and functions cannot replace it. Both wired hooks register as `bash`
invoking `hooks/run-python-hook.sh`, so `bash` must resolve on `PATH` (Git Bash on Windows) before
the launcher can resolve Python (#1504). The guard registers on two surfaces: a
there, so treat the number printed here as a convenience copy). Claude Code launches the
skill-scoped guard in shell-free exec form; guarded engine calls must use the same absolute
interpreter reported by that guard, so Bash aliases and functions cannot replace it. Both wired
hooks register in **shell form** — the `command` string names `hooks/run-python-hook.sh` directly
with `"shell": "bash"` and no `args` — so Claude Code routes them through Git Bash itself instead
Comment thread
kyle-sexton marked this conversation as resolved.
of resolving `bash` on `PATH`. Registering them in exec form as `"command": "bash"` is the
regression #1416 tracks: on Windows that bare `PATH` lookup finds the WSL relay
`System32\bash.exe` before Git Bash, the launch fails, and a failed hook launch is non-blocking —
so the guard silently enforces nothing. The launcher then resolves Python (#1504). The guard
registers on two surfaces: a
plugin-level **engine gate** (`hooks/hooks.json`) that acts only on commands referencing the
engine — deferring everything else instantly — and enforces the kill switch and data-root
authority; and the skill-scoped **belt** inside the `clean` skill's context, which adds the
Expand Down Expand Up @@ -190,22 +195,42 @@ hand-cleaning the zone.
or unreadable value fails closed to enabled. The one residual a hook cannot read is a value supplied only
via a session `--settings` file. The skill's own kill-switch probe + skill-content value remain a
defense-in-depth honoring layer over the guard.
- **Trust-surface record (0.7.0; updated 0.9.0):** the plugin-level `hooks/hooks.json` PreToolUse
- **Trust-surface record (0.7.0; updated 0.17.8):** the plugin-level `hooks/hooks.json` PreToolUse
registration is a NEW trust surface (a hook that launches in every consumer session), added
deliberately for guard-enforced audit-only mode and data-root authority (#1106 decision, Option E —
split registration). Its blast radius is bounded by design: exec form (no shell), bundled
standard-library script only, instant no-output deferral for any command not referencing the engine,
and no new capability beyond what the skill-scoped deployment already did during active cleanup.
Known costs, accepted: one `python3` launch per Bash/PowerShell call, and on a machine where
`python3` resolves to the Windows Store alias stub the launch fails on every call (tracked with
remediation detection in #1110). **0.9.0 delta:** the gate no longer carries a `${user_config.*}`
split registration). Its blast radius is bounded by design: a fixed launch string authored in the
plugin's own `hooks.json` (see the 0.17.8 delta for exactly what that bounds now that the string
reaches a shell), bundled standard-library scripts only, instant no-output deferral for any command
not referencing the engine, and no new capability beyond what the skill-scoped deployment already
did during active cleanup. Known costs, accepted: one launcher shell plus one Python launch per
Bash/PowerShell call, and on a machine where no Python 3 interpreter resolves at all the gate fails
open on every call — the `Stop` detector emits a `systemMessage` for that case, so the blind spot is
visible rather than silent (#1110, #1504). **0.9.0 delta:** the gate no longer carries a `${user_config.*}`
argument (which, unset, dropped the whole hook and left the gate inert on a default install); it now
registers unconditionally and resolves the kill switch by **reading** the user `settings.json` and the
platform managed-settings.json. The added trust surface is that settings-file *read* — bounded to a
single `pluginConfigs` value, from the user file (located from `${CLAUDE_PLUGIN_ROOT}`) and the
root-owned managed file at its fixed system path, no write. Both are the plugin's own documented CC
config, sanctioned by the acceptance review's operator-home carve-out (criterion 4). This entry is the
plugin-acceptance review delta for the change. A direct `hygiene.py` invocation outside that skill does not read the toggle and
plugin-acceptance review delta for the change. **0.17.8 delta (launch form):** both wired hooks now
register in **shell form** — the `command` string names `hooks/run-python-hook.sh` with
`"shell": "bash"` and no `args` — because exec form's bare `PATH` lookup for `bash` resolved to the
WSL relay on Windows and the guard silently never launched (#1416). Stated plainly: a shell now
parses the launch string, so "no shell involved" is no longer what bounds this surface. What bounds
it instead is that the string is a **fixed literal** in the plugin's own `hooks.json` with no model-,
repo-, or session-supplied text interpolated into it; the only values substituted are Claude Code's
own `${CLAUDE_PLUGIN_ROOT}` and `${CLAUDE_PLUGIN_DATA}`, and each is double-quoted, so the shell's
re-tokenization reproduces the exec-form argument vector byte-for-byte — verified for both hooks
against roots containing spaces and backslashes. The limits of that quoting belong in the record
too: Claude Code substitutes those placeholders *textually* before bash parses the result, so the
double quotes bound whitespace and backslashes but would not neutralize a `$` or a backtick inside a
substituted value (both placeholders resolve under Claude Code's own install and data roots). The
invariant is therefore **maintained by test**, not structural — `hooks/run-python-hook.test.sh`
asserts the launcher is named in `command`, `args` is absent, `shell: bash` is declared, and every
placeholder is double-quoted, and `test_hygiene.py`'s hook helpers are form-agnostic so a shell-form
entry can never make an assertion vacuously green. Interpolating anything beyond those two
placeholders into the command string would open a live injection surface; a repo-wide CI gate for
this defect class is proposed in #2569. A direct `hygiene.py` invocation outside that skill does not read the toggle and
answers only to the engine's own preview/approval-token gate. The toggle can only narrow the
destructive surface, never widen it (see [the safety model](skills/clean/reference/safety-model.md)
for the degraded-mode detail). No credentials. Policy comes from an explicit invocation
Expand Down
22 changes: 4 additions & 18 deletions plugins/disk-hygiene/hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,8 @@
"hooks": [
{
"type": "command",
"command": "bash",
"args": [
"${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 \"${CLAUDE_PLUGIN_ROOT}\"/skills/clean/scripts/destructive_guard.py --mode engine-gate --plugin-root \"${CLAUDE_PLUGIN_ROOT}\" --authorized-data-root \"${CLAUDE_PLUGIN_DATA}\"",
"shell": "bash",
"timeout": 60
}
]
Expand All @@ -27,13 +18,8 @@
"hooks": [
{
"type": "command",
"command": "bash",
"args": [
"${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 \"${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
21 changes: 15 additions & 6 deletions plugins/disk-hygiene/hooks/run-python-hook.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,21 @@
# Launch disk-hygiene wired hooks through a Python 3 interpreter resolved
# independently of a bare `python3` on PATH (#1504).
#
# Claude Code registers plugin hooks in exec form: the `command` field is
# resolved on PATH with no shell. When `python3` is absent, broken, or resolves
# to the zero-length WindowsApps App Execution Alias stub, both the guard and its
# Stop detector died the same way — the detector could not observe the guard's
# fail-open. This launcher is registered as `bash` (available in Git Bash on
# Windows) and resolves a real interpreter before exec'ing the target script.
# When `python3` is absent, broken, or resolves to the zero-length WindowsApps
# App Execution Alias stub, both the guard and its Stop detector died the same
# way — the detector could not observe the guard's fail-open. This launcher
# resolves a real interpreter before exec'ing the target script.
#
# hooks.json invokes this file in SHELL FORM — the `command` string names this
# script by path and carries its arguments, with no `args` key. Claude Code
# routes shell form through Git Bash on Windows, resolved by Claude Code itself.
# It must NOT be registered in exec form as `"command": "bash"` + `args`: exec
# form is a bare PATH lookup, and on Windows `bash` resolves to the WSL relay
# `System32\bash.exe` before Git Bash, which fails with
# `execvpe(/bin/bash) failed` (#1006, regressed by #1504). A hook that fails to
# launch is a non-blocking error, so the guard silently enforces nothing.
# Every path placeholder in the command string must stay double-quoted; the
# shell re-tokenizes the string, and plugin roots contain spaces.
#
# When no interpreter resolves:
# * guard_launch_monitor.py — emit a once-per-run systemMessage on stdout
Expand Down
44 changes: 35 additions & 9 deletions plugins/disk-hygiene/hooks/run-python-hook.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -33,24 +33,50 @@ assert_contains() {
fi
}

# --- launcher is executable and hooks.json wires bash + this script ---
# --- hooks.json wires this launcher in portable shell form ---
#
# These assert the PORTABILITY PROPERTY, not a literal spelling. The previous
# revision asserted `.command == "bash"` with the script in `.args`, which
# encoded the #1006 defect as the contract: exec form (`args` present) resolves
# `command` as a bare PATH lookup, and on Windows `bash` finds the WSL relay
# `System32\bash.exe` before Git Bash. The launch fails, and a failed hook
# launch is non-blocking — so the guard silently enforced nothing.
HOOKS_JSON="$SCRIPT_DIR/hooks.json"
if ! command -v jq >/dev/null 2>&1; then
echo "SKIP: jq required" >&2
exit 0
fi

for hook_name in destructive_guard.py guard_launch_monitor.py; do
command_line="$(jq -r --arg target "$hook_name" '
entry="$(jq -c --arg target "$hook_name" '
.hooks | to_entries[] | .value[]? | .hooks[]? |
select(.args[]? | contains($target)) | .command
select(.command | contains($target))
' "$HOOKS_JSON" | head -n1)"
assert_eq "hooks.json command for $hook_name is bash" "bash" "$command_line"
launcher_arg="$(jq -r --arg target "$hook_name" '
.hooks | to_entries[] | .value[]? | .hooks[]? |
select(.args[]? | contains($target)) | .args[0]
' "$HOOKS_JSON" | head -n1)"
assert_contains "hooks.json args[0] for $hook_name is the launcher" "run-python-hook.sh" "$launcher_arg"
if [[ -z "$entry" ]]; then
fail "hooks.json has no command hook referencing $hook_name"
fi

command_line="$(jq -r '.command' <<<"$entry")"
assert_contains "hooks.json command for $hook_name invokes the launcher" \
"run-python-hook.sh" "$command_line"

# Shell form only: `args` present would switch Claude Code to exec form, where
# `command` is a bare PATH lookup and `shell` is ignored.
assert_eq "hooks.json entry for $hook_name omits args (shell form)" \
"null" "$(jq -r '.args // "null" | if type == "array" then "present" else . end' <<<"$entry")"

# Explicit `shell: bash`. Shell form otherwise falls back to PowerShell on a
# Windows host with no Git Bash detected, which cannot run a .sh launcher.
assert_eq "hooks.json entry for $hook_name declares shell bash" \
"bash" "$(jq -r '.shell // ""' <<<"$entry")"

# Every path placeholder must be double-quoted: the shell re-tokenizes the
# command string, and plugin roots routinely contain spaces.
unquoted="$(grep -oE '(^|[^"])\$\{CLAUDE_PLUGIN_(ROOT|DATA)\}|\$\{CLAUDE_PLUGIN_(ROOT|DATA)\}([^"]|$)' <<<"$command_line" || true)"
if [[ -n "$unquoted" ]]; then
fail "hooks.json command for $hook_name has an unquoted path placeholder: $unquoted"
fi
pass "hooks.json command for $hook_name double-quotes every path placeholder"
done

# --- monitor mode without python emits systemMessage JSON ---
Expand Down
Loading
Loading