From 061e02f9b07f69f6146055c38238fddc40b70c2c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 27 Sep 2026 20:27:59 +0000 Subject: [PATCH 1/2] feat(disk-hygiene): hand /disk-hygiene:clean the guard's interpreter and data root up front A UserPromptExpansion hook runs engine_context.py through the same launcher and --plugin-root argument as the skill guard and injects the guard's absolute Python and authorized --data-root as context, so a run no longer opens with a deliberately denied scan. The denial route stays as the fallback. Closes #4215 Co-authored-by: Kyle Sexton --- .../disk-hygiene/.claude-plugin/plugin.json | 2 +- plugins/disk-hygiene/CHANGELOG.md | 6 + plugins/disk-hygiene/README.md | 6 + plugins/disk-hygiene/hooks/hooks.json | 16 +- .../hooks/run-python-hook.test.sh | 2 +- plugins/disk-hygiene/skills/clean/SKILL.md | 14 +- .../skills/clean/reference/safety-model.md | 13 ++ .../skills/clean/scripts/engine_context.py | 69 ++++++++ .../clean/scripts/engine_context.test.sh | 31 ++++ .../clean/scripts/test_engine_context.py | 148 ++++++++++++++++++ 10 files changed, 298 insertions(+), 9 deletions(-) create mode 100644 plugins/disk-hygiene/skills/clean/scripts/engine_context.py create mode 100755 plugins/disk-hygiene/skills/clean/scripts/engine_context.test.sh create mode 100644 plugins/disk-hygiene/skills/clean/scripts/test_engine_context.py diff --git a/plugins/disk-hygiene/.claude-plugin/plugin.json b/plugins/disk-hygiene/.claude-plugin/plugin.json index 4f3c5c3e78..72ec66f50e 100644 --- a/plugins/disk-hygiene/.claude-plugin/plugin.json +++ b/plugins/disk-hygiene/.claude-plugin/plugin.json @@ -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", diff --git a/plugins/disk-hygiene/CHANGELOG.md b/plugins/disk-hygiene/CHANGELOG.md index 782e842ab1..6cd8d7f0b3 100644 --- a/plugins/disk-hygiene/CHANGELOG.md +++ b/plugins/disk-hygiene/CHANGELOG.md @@ -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 diff --git a/plugins/disk-hygiene/README.md b/plugins/disk-hygiene/README.md index d23e99fa1a..fe04ed6b0a 100644 --- a/plugins/disk-hygiene/README.md +++ b/plugins/disk-hygiene/README.md @@ -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 diff --git a/plugins/disk-hygiene/hooks/hooks.json b/plugins/disk-hygiene/hooks/hooks.json index afab33b483..1d2ec2afa9 100644 --- a/plugins/disk-hygiene/hooks/hooks.json +++ b/plugins/disk-hygiene/hooks/hooks.json @@ -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": [ { @@ -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..." + } + ] + } ] } } diff --git a/plugins/disk-hygiene/hooks/run-python-hook.test.sh b/plugins/disk-hygiene/hooks/run-python-hook.test.sh index 38749c2a9d..7720cea95a 100755 --- a/plugins/disk-hygiene/hooks/run-python-hook.test.sh +++ b/plugins/disk-hygiene/hooks/run-python-hook.test.sh @@ -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)) diff --git a/plugins/disk-hygiene/skills/clean/SKILL.md b/plugins/disk-hygiene/skills/clean/SKILL.md index dbcf27523b..cd0191b2b6 100644 --- a/plugins/disk-hygiene/skills/clean/SKILL.md +++ b/plugins/disk-hygiene/skills/clean/SKILL.md @@ -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 `` 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 ``, 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. diff --git a/plugins/disk-hygiene/skills/clean/reference/safety-model.md b/plugins/disk-hygiene/skills/clean/reference/safety-model.md index 34f88fa9fe..692dffecc2 100644 --- a/plugins/disk-hygiene/skills/clean/reference/safety-model.md +++ b/plugins/disk-hygiene/skills/clean/reference/safety-model.md @@ -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. diff --git a/plugins/disk-hygiene/skills/clean/scripts/engine_context.py b/plugins/disk-hygiene/skills/clean/scripts/engine_context.py new file mode 100644 index 0000000000..8520fe34f8 --- /dev/null +++ b/plugins/disk-hygiene/skills/clean/scripts/engine_context.py @@ -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 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()) diff --git a/plugins/disk-hygiene/skills/clean/scripts/engine_context.test.sh b/plugins/disk-hygiene/skills/clean/scripts/engine_context.test.sh new file mode 100755 index 0000000000..07a05b947c --- /dev/null +++ b/plugins/disk-hygiene/skills/clean/scripts/engine_context.test.sh @@ -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" diff --git a/plugins/disk-hygiene/skills/clean/scripts/test_engine_context.py b/plugins/disk-hygiene/skills/clean/scripts/test_engine_context.py new file mode 100644 index 0000000000..6d0275f54f --- /dev/null +++ b/plugins/disk-hygiene/skills/clean/scripts/test_engine_context.py @@ -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() From 740b389e57fb18ad6f9f7ed0568866966a88dd3e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 27 Sep 2026 20:43:38 +0000 Subject: [PATCH 2/2] fix(disk-hygiene): mark new engine_context scripts executable Shebang'd Python entrypoints and their test drivers must be 100755 so the exec-bit hygiene gate matches the rest of plugins/disk-hygiene/skills/*/scripts. Co-authored-by: Kyle Sexton --- plugins/disk-hygiene/skills/clean/scripts/engine_context.py | 0 plugins/disk-hygiene/skills/clean/scripts/test_engine_context.py | 0 2 files changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 plugins/disk-hygiene/skills/clean/scripts/engine_context.py mode change 100644 => 100755 plugins/disk-hygiene/skills/clean/scripts/test_engine_context.py diff --git a/plugins/disk-hygiene/skills/clean/scripts/engine_context.py b/plugins/disk-hygiene/skills/clean/scripts/engine_context.py old mode 100644 new mode 100755 diff --git a/plugins/disk-hygiene/skills/clean/scripts/test_engine_context.py b/plugins/disk-hygiene/skills/clean/scripts/test_engine_context.py old mode 100644 new mode 100755