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.4.2",
"version": "0.4.3",
"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
18 changes: 18 additions & 0 deletions plugins/disk-hygiene/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,24 @@
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.4.3]

### Fixed

- **The `clean` skill's destructive-safety guard now launches via a resolvable `python3`.** The
PreToolUse hook ran in exec form via the unqualified interpreter `python`, which stock macOS and
many Linux distros do not ship (only `python3`). Because Claude Code treats a failed hook launch
as a non-blocking error, an unresolvable `python` fails the guard open — `rm -rf`, engine `apply`,
and other destructive shapes stop being intercepted on the very POSIX hosts the safety model
relies on — and a legacy `python` 2.x resolving first would crash the guard on modern syntax. The
hook now names `python3`. A new regression test (`test_skill_hook_interpreter_is_python3_and_resolves`)
locks the config at `python3` and probes that a runnable `python3` reports a 3.11+ interpreter.
Enforcement remains bounded by resolution: on a host without a resolvable `python3` the launch
still fails open on the manual PowerShell deletion lane (engine `apply` is already unsupported on
Windows/macOS), so the per-path human approval that lane already requires and the consumer's
baseline permission policy stay the backstop, and `/disk-hygiene:setup check` reports interpreter
resolution. (#380)

## [0.4.2]

### Fixed
Expand Down
14 changes: 13 additions & 1 deletion plugins/disk-hygiene/skills/clean/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ hooks:
- matcher: "Bash|PowerShell"
hooks:
- type: command
command: "python"
command: "python3"
Comment thread
kyle-sexton marked this conversation as resolved.
args: ["${CLAUDE_PLUGIN_ROOT}/skills/clean/scripts/destructive_guard.py", "--authorized-data-root", "${CLAUDE_PLUGIN_DATA}"]
Comment thread
kyle-sexton marked this conversation as resolved.
---

Expand Down Expand Up @@ -226,6 +226,18 @@ sparse files, hard links, compression, and delayed allocation affect it.
research uses non-Bash read-only tools; only literal-word bundled scan, preview, and apply shapes
using the hook runtime's same absolute executable pass. Shell expansions, globs, splitting/escape
forms, operators, redirections, aliases, and exported functions fail closed.
- The guard hook launches in exec form via `python3`, resolved on `PATH` with no shell (`python3`,
not bare `python`, because stock macOS and many Linux distros ship only `python3` and a legacy
`python` 2.x would crash the guard on modern syntax). Enforcement is therefore only as strong as
that resolution: on a host where `python3` does not resolve to a 3.11+ interpreter the PreToolUse
launch fails, and Claude Code treats a failed hook launch as a non-blocking error, so the guard
does not intercept there. Concretely, the exposure is the manual PowerShell deletion lane: engine
`apply` is unsupported on Windows and macOS and elsewhere runs only behind the guard's own `ask`,
so no silent auto-delete path opens, but the guard's PowerShell belt that turns a deletion spelling
into a final human prompt is lost. The backstops that remain are the per-path human approval the
manual-handoff lane already requires and the consumer's baseline permission policy — defense-in-depth
lost, not preserved. `/disk-hygiene:setup check` reports whether the interpreter resolves on this
machine.
- The PowerShell lane is the inverse tradeoff: it stays open for read-only support work (git, gh,
metadata probes) and instead hard-denies engine invocations and turns known deletion spellings
into a final human permission prompt. It is a raised bar, not a fail-closed lane; the engine's
Expand Down
44 changes: 44 additions & 0 deletions plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py
Original file line number Diff line number Diff line change
Expand Up @@ -1507,6 +1507,50 @@ def test_skill_hook_passes_authorized_data_root_flag_matching_constant(
flag_index = args.index(guard._AUTHORIZED_DATA_ROOT_FLAG)
self.assertEqual("${CLAUDE_PLUGIN_DATA}", args[flag_index + 1])

def test_skill_hook_interpreter_is_python3_and_resolves(self) -> None:
"""Lock the guard's launch interpreter and prove it resolves.

The PreToolUse hook runs in exec form, so `command` is resolved on PATH
with no shell. Bare `python` is absent on stock macOS and many Linux
distros (and a legacy 2.x would crash the guard), which fails the launch
open — the guard never intercepts. The static half locks the config at
`python3`. The runtime half is the "interpreter actually resolves" probe:
it skips where `python3` cannot run — absent from PATH, or resolved to a
name that will not execute (a Windows App Execution Alias stub resolves
to a real path yet exits non-zero) — because that is the documented
residual host gap, not a regression; only a runnable `python3` is
asserted to be 3.11+.
"""
skill = SCRIPT_DIR.parent / "SKILL.md"
command_line = next(
(
line
for line in skill.read_text(encoding="utf-8").splitlines()
if line.strip().startswith("command:")
),
None,
)
self.assertIsNotNone(command_line, "frontmatter hook command line not found")
assert command_line is not None
interpreter = command_line.split(":", 1)[1].strip().strip('"')
self.assertEqual("python3", interpreter)

resolved = shutil.which(interpreter)
if resolved is None:
self.skipTest(f"{interpreter} does not resolve on this host")
probe = subprocess.run(
[resolved, "-c", "import sys; print('%d.%d' % sys.version_info[:2])"],
capture_output=True,
text=True,
)
if probe.returncode != 0 or not probe.stdout.strip():
self.skipTest(
f"{interpreter} resolved to a non-runnable interpreter "
f"({(probe.stderr or probe.stdout).strip()})"
)
major, minor = (int(part) for part in probe.stdout.strip().split("."))
self.assertGreaterEqual((major, minor), (3, 11), probe.stdout)

def test_guard_scan_max_depth_accepts_only_positive_integer_literal(self) -> None:
script = SCRIPT_DIR / "hygiene.py"
base = f'"{self.python_command()}" "{script}" scan --target t --output s'
Expand Down