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.17",
"version": "0.24.0",
"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
6 changes: 6 additions & 0 deletions plugins/disk-hygiene/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@
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.24.0] - 2026-09-27

### Added

- **`/disk-hygiene:clean` no longer opens with a deliberately denied tool call.** A new `UserPromptExpansion` hook, matching `disk-hygiene:clean$`, runs `skills/clean/scripts/engine_context.py` through the same launcher and with the same `--plugin-root` argument as the skill guard, and hands the skill the guard's absolute interpreter (`hook_python`) and authorized `--data-root` (`data_root`) as context before the skill loads. The skill uses them from the first call. The guard still judges every call, and when the note is absent or says `data_root: none` the skill falls back to the denial route as before.

## [0.23.17] - 2026-09-27

### Fixed
Expand Down
6 changes: 6 additions & 0 deletions plugins/disk-hygiene/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,12 @@ retroactively scans a prior session's transcript. Every hook registration routes
PATH, so when `python3` is the WindowsApps alias stub or otherwise unresolvable, the detector still
emits a `systemMessage` even though the guard cannot run (#1504).

**The guard's interpreter and data root arrive with the command.** A `UserPromptExpansion` hook
(`skills/clean/scripts/engine_context.py`) runs when `/disk-hygiene:clean` expands and hands the
skill the guard's absolute Python and authorized `--data-root`, resolved by the guard's own code, so
a run does not open with a deliberately denied call to learn them (#4215). It grants nothing; the
guard still judges every call.

**Windows `python3` gotcha, the Store alias stub fails the guard open.** Every hook resolves Python
through `hooks/run-python-hook.sh` (rejecting the zero-length `WindowsApps\python3.exe` App
Execution Alias stub and falling through to `python`, then `py -3`) before exec'ing the guard, the
Expand Down
16 changes: 15 additions & 1 deletion plugins/disk-hygiene/hooks/hooks.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"description": "Blocks a destructive disk-hygiene delete that falls outside its authorized roots, and detects the guard silently failing to launch.",
"description": "Blocks a destructive disk-hygiene delete that falls outside its authorized roots, detects the guard silently failing to launch, and hands /disk-hygiene:clean the guard's interpreter and data root when it expands.",
"hooks": {
"PreToolUse": [
{
Expand Down Expand Up @@ -57,6 +57,20 @@
}
]
}
],
"UserPromptExpansion": [
{
"matcher": "disk-hygiene:clean$",
"hooks": [
{
"type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}\"/hooks/run-python-hook.sh \"${CLAUDE_PLUGIN_ROOT}\"/skills/clean/scripts/engine_context.py --plugin-root \"${CLAUDE_PLUGIN_ROOT}\"",
"shell": "bash",
"timeout": 20,
"statusMessage": "Resolving the disk-hygiene guard's interpreter and data root..."
}
]
}
]
}
}
2 changes: 1 addition & 1 deletion plugins/disk-hygiene/hooks/run-python-hook.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -433,7 +433,7 @@ if ! command -v jq >/dev/null 2>&1; then
exit 0
fi

for hook_name in destructive_guard.py guard_launch_monitor.py; do
for hook_name in destructive_guard.py guard_launch_monitor.py engine_context.py; do
entry="$(jq -c --arg target "$hook_name" '
.hooks | to_entries[] | .value[]? | .hooks[]? |
select(.command | contains($target))
Expand Down
14 changes: 8 additions & 6 deletions plugins/disk-hygiene/skills/clean/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,12 +97,14 @@ target; scan those without the flag.
value could not be read. The guard enforces the same toggle independently and denies both mutation
lanes in audit-only mode (`reference/safety-model.md`), so run the probe anyway, to state the
configured value accurately and stop before proposing work the guard would deny. The guard is the
backstop, not the sole enforcer. It reports its absolute Python interpreter and the authorized
`--data-root` value in denial guidance; use that exact interpreter path as `<hook-python>` for
every engine call; bare `python`/`python3` is rejected because Bash aliases and functions can
replace them. If either value is not known yet, submit the otherwise exact scan shape once with
bare `python`: the guard must deny it and report both, after which retry the scan with the
absolute interpreter and the reported `--data-root`. If the reported interpreter is older than
backstop, not the sole enforcer. Every engine call and the 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 both as
`hook_python` and `data_root`, resolved by the guard's own code before the skill loads; use them
from the first call. Only when that note is absent, or says `data_root: none`, fall back to the
guard's denial guidance, which reports both: submit the otherwise exact scan shape once with
bare `python`, then retry with the reported values. If the reported interpreter 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.
Expand Down
13 changes: 13 additions & 0 deletions plugins/disk-hygiene/skills/clean/reference/safety-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,19 @@ literal unsubstituted placeholder counts as absent); absent every channel the fl
otherwise exact call that omits it is denied, because the engine would then fall back to the raw
`CLAUDE_PLUGIN_DATA` value, which a repository `env` block can set.

**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()` results as `additionalContext`, so the skill needs no denied call to
learn them. It grants nothing: the guard still judges every call, and a hook that fails prints
nothing and leaves the skill on the denial route. One divergence is possible: a plugin hook receives
`CLAUDE_PLUGIN_DATA` in its environment and a skill hook may not, so where neither derivation
resolves, the note can name an env-supplied root the skill guard then denies. That denial names
the fix. Verified 2026-09-27 against https://code.claude.com/docs/en/hooks ("UserPromptExpansion":
typing `/skillname` fires it, it matches on `command_name`, and `additionalContext` reaches Claude
alongside the expanded prompt); recheck when that section changes, or if a release note names the
event. Whether `command_name` carries the leading `/` was not observed, so the matcher admits both.

`--max-depth` accepts only a bare positive-integer literal. `--confirmed-large-scan`, `--quiet`
and `--root-children` are the valueless scan flags; the guard permits at most one of each and
rejects any trailing value, so the scan grammar stays exact.
Expand Down
69 changes: 69 additions & 0 deletions plugins/disk-hygiene/skills/clean/scripts/engine_context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""Hand ``/disk-hygiene:clean`` the guard's interpreter and data root up front.

Registered as a ``UserPromptExpansion`` hook matching the clean command. Every
engine call must name the guard's absolute Python and its authorized
``--data-root``; without this hook the skill learns both only from a denial,
so each run opened with a deliberately failing tool call (#4215).

Both values come from the guard's own functions, run under the same launcher
and with the same ``--plugin-root`` argument the skill-frontmatter guard gets,
so they are the values that guard will accept. The guard still checks every
call; this hook only saves the discovery round trip.

Report-only: it never blocks the expansion. It always exits 0, and it prints
nothing when it cannot produce the values, which leaves the skill on its
denial-guidance fallback.
"""

from __future__ import annotations

import json
import sys

import destructive_guard


def context_text() -> str:
python = destructive_guard._display_python()
data_root = destructive_guard._display_data_root(
destructive_guard.resolve_authorized_data_root()
)
if data_root:
root_line = f'data_root: "{data_root}"'
else:
root_line = (
"data_root: none (the guard resolved no authorized data root, so "
"every engine call fails closed; its denial names the fix)"
)
return (
"disk-hygiene guard values for this session, resolved by the guard's "
"own code:\n"
f'hook_python: "{python}"\n'
f"{root_line}\n"
"Use hook_python as <hook-python> and data_root as every --data-root "
"value."
)


def main() -> int:
try:
sys.stdin.read()
text = context_text()
except Exception: # noqa: BLE001 - a failed hint must never block the command
return 0
print(
json.dumps(
{
"hookSpecificOutput": {
"hookEventName": "UserPromptExpansion",
"additionalContext": text,
}
}
)
)
return 0


if __name__ == "__main__":
raise SystemExit(main())
31 changes: 31 additions & 0 deletions plugins/disk-hygiene/skills/clean/scripts/engine_context.test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Cross-platform contract wrapper for the engine-context test suite.
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

# shellcheck source=../../../scripts/test-wrapper-lib.sh
source "$SCRIPT_DIR/../../../scripts/test-wrapper-lib.sh"

ENGINE="$SCRIPT_DIR/hygiene.py"
FLOOR=""
test_wrapper::floor_to FLOOR "$ENGINE"
if [[ -z "$FLOOR" ]]; then
echo "FAIL: could not parse MIN_PYTHON from $ENGINE" >&2
exit 1
fi

PYTHON=""
test_wrapper::interpreter_to PYTHON
if [[ -z "$PYTHON" ]]; then
echo "SKIP: Python ${FLOOR}+ not found" >&2
exit 0
fi

FLOOR_CHECK=""
test_wrapper::floor_check_to FLOOR_CHECK "$FLOOR"
"$PYTHON" -c "$FLOOR_CHECK" || {
echo "SKIP: Python ${FLOOR}+ required" >&2
exit 0
}
"$PYTHON" -m unittest -v "$SCRIPT_DIR/test_engine_context.py"
148 changes: 148 additions & 0 deletions plugins/disk-hygiene/skills/clean/scripts/test_engine_context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
#!/usr/bin/env python3
"""Tests for the ``UserPromptExpansion`` context hook of ``/disk-hygiene:clean`` (#4215).

The contract under test: the values the hook hands the skill are values the
skill-frontmatter guard admits, so the first engine call needs no denial to
discover them.
"""

from __future__ import annotations

import io
import json
import os
import re
import sys
import tempfile
import unittest
from contextlib import redirect_stdout
from pathlib import Path
from unittest import mock

SCRIPT_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(SCRIPT_DIR))

import destructive_guard as guard # noqa: E402 (path set above)
import engine_context # noqa: E402 (path set above)

HOOKS_JSON = SCRIPT_DIR.parents[2] / "hooks" / "hooks.json"


class EngineContextTest(unittest.TestCase):
def setUp(self) -> None:
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
config = Path(temporary.name).resolve()
self.plugin_root = (
config / "plugins" / "cache" / "melodic-software" / "disk-hygiene" / "1.2.3"
)
self.plugin_root.mkdir(parents=True)
self.managed = config / "managed-settings.json"
guard._directory_marketplace_install.cache_clear()
self.addCleanup(guard._directory_marketplace_install.cache_clear)
for patch in (
mock.patch.dict(os.environ, {}, clear=True),
mock.patch.object(guard, "_trusted_config_dir", lambda: None),
mock.patch.object(
guard.killswitch_config, "managed_settings_path", lambda: self.managed
),
):
patch.start()
self.addCleanup(patch.stop)

def run_hook(self, argv: list[str]) -> tuple[int, str]:
stdout = io.StringIO()
with (
mock.patch(
"sys.stdin", io.StringIO('{"command_name":"disk-hygiene:clean"}')
),
mock.patch.object(guard.sys, "argv", argv),
redirect_stdout(stdout),
):
code = engine_context.main()
return code, stdout.getvalue()

def context(self, argv: list[str]) -> str:
code, out = self.run_hook(argv)
self.assertEqual(0, code)
output = json.loads(out)["hookSpecificOutput"]
self.assertEqual("UserPromptExpansion", output["hookEventName"])
return output["additionalContext"]

def guard_decision(self, command: str) -> str:
argv = ["destructive_guard.py", "--plugin-root", str(self.plugin_root)]
stdout = io.StringIO()
with (
mock.patch(
"sys.stdin",
io.StringIO(json.dumps({"tool_input": {"command": command}})),
),
mock.patch.object(guard.sys, "argv", argv),
redirect_stdout(stdout),
):
self.assertEqual(0, guard.main())
return json.loads(stdout.getvalue())["hookSpecificOutput"]["permissionDecision"]

def test_reported_values_pass_the_skill_guard_on_the_first_call(self) -> None:
text = self.context(
["engine_context.py", "--plugin-root", str(self.plugin_root)]
)
python = re.search(r'^hook_python: "([^"]+)"$', text, re.MULTILINE)
data_root = re.search(r'^data_root: "([^"]+)"$', text, re.MULTILINE)
assert python and data_root, text
engine = SCRIPT_DIR / "hygiene.py"
command = (
f'"{python.group(1)}" "{engine}" scan --target t --output s '
f'--data-root "{data_root.group(1)}"'
)
self.assertEqual("allow", self.guard_decision(command))

def test_bare_python_with_the_same_values_is_still_denied(self) -> None:
text = self.context(
["engine_context.py", "--plugin-root", str(self.plugin_root)]
)
data_root = re.search(r'^data_root: "([^"]+)"$', text, re.MULTILINE)
assert data_root, text
engine = SCRIPT_DIR / "hygiene.py"
command = (
f'python "{engine}" scan --target t --output s '
f'--data-root "{data_root.group(1)}"'
)
self.assertEqual("deny", self.guard_decision(command))

def test_no_resolvable_data_root_says_so_and_still_exits_zero(self) -> None:
text = self.context(["engine_context.py"])
self.assertIn("data_root: none", text)
self.assertRegex(text, r'(?m)^hook_python: "[^"]+"$')

def test_a_failure_prints_nothing_and_never_blocks(self) -> None:
with mock.patch.object(
engine_context, "context_text", side_effect=RuntimeError("boom")
):
code, out = self.run_hook(["engine_context.py"])
self.assertEqual((0, ""), (code, out))


class HooksJsonRegistrationTest(unittest.TestCase):
def rows(self) -> list[dict]:
config = json.loads(HOOKS_JSON.read_text(encoding="utf-8"))
return config["hooks"]["UserPromptExpansion"]

def test_row_passes_the_same_plugin_root_argument_as_the_skill_guard(self) -> None:
commands = [hook["command"] for row in self.rows() for hook in row["hooks"]]
self.assertEqual(1, len(commands), commands)
self.assertIn("engine_context.py", commands[0])
self.assertIn('--plugin-root "${CLAUDE_PLUGIN_ROOT}"', commands[0])
self.assertNotIn("--authorized-data-root", commands[0])

def test_matcher_fires_only_for_the_clean_command(self) -> None:
(row,) = self.rows()
matcher = re.compile(row["matcher"])
for name in ("disk-hygiene:clean", "/disk-hygiene:clean"):
self.assertTrue(matcher.search(name), name)
for name in ("disk-hygiene:setup", "repo-hygiene:clean", "clean"):
self.assertFalse(matcher.search(name), name)


if __name__ == "__main__":
unittest.main()
Loading