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.5.0",
"version": "0.6.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, changed entries, and live-handle uncertainty fail closed.",
"author": {
"name": "Melodic Software",
Expand Down
28 changes: 28 additions & 0 deletions plugins/disk-hygiene/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,34 @@
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.6.0]

### Added

- **Deterministic kill-switch probe** (`skills/setup/scripts/kill_switch_probe.py`): a report-only,
stdlib-only read of the configured `disk_hygiene_enabled` value from
`pluginConfigs[<plugin-id>].options` in the user `settings.json` (`CLAUDE_CONFIG_DIR`-aware). It
emits one JSON line with the `effective` boolean, its `source`
(`configured` / `default` / `indeterminate`), a `degraded` flag, and the matched entries. The
guard's Bash allowlist now permits exactly the argument-free bundled probe invocation (any
argument, bare `python`, or a different path stays denied).

### Fixed

- **`setup check` no longer reports the kill switch from an unexpanded body token.** Step 4
previously emitted `${user_config.disk_hygiene_enabled}` in the skill body with the rule
"unexpanded or empty means default `true`", so a configured `false` (audit-only mode) whose
token failed to expand was misreported as enabled — a false-negative on the safety-critical
setting the check exists to verify. Current plugin docs state non-sensitive `${user_config.*}`
values substitute in skill content, but a live run observed the token unexpanded, so body-token
expansion cannot be load-bearing for a safety report. `check` now reports the probe's
deterministic result with provenance, degrades honestly ("could not read the configured toggle;
assuming default `true`") when no definitive read is possible, and treats the body token as at
most a cross-check whose contradiction is reported rather than silently resolved. The `clean`
skill's audit-only instruction likewise stops treating an unexpanded token as "unset = enabled"
and resolves the toggle through the same probe; enforcement remains with the guard's
runtime-substituted `--disk-hygiene-enabled` hook argument (0.4.4).

## [0.5.0]

### Added
Expand Down
8 changes: 6 additions & 2 deletions plugins/disk-hygiene/skills/clean/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,12 @@ directory, symlink, or Windows reparse point.
- Never elevate, trigger UAC/sudo, install a dependency, close another process's handle, or disable a
retention mechanism. Report `needs-elevation` or `handle-state-unverified` and stop that tier.
- If the `disk_hygiene_enabled` userConfig option is `false` (its value here is
`${user_config.disk_hygiene_enabled}`; a literal unexpanded token means unset = enabled), audit
only and explain why execution is disabled. In this audit-only mode the guard denies every
`${user_config.disk_hygiene_enabled}`), audit only and explain why execution is disabled. A
literal unexpanded token is not evidence the toggle is unset — resolve it deterministically by
running the bundled probe (the guard allows exactly this argument-free shape):
`"<hook-python>" "${CLAUDE_PLUGIN_ROOT}/skills/setup/scripts/kill_switch_probe.py"` and honor
the `effective` value it reports; on `degraded: true` proceed as enabled but say the configured
value could not be read. In this audit-only mode the guard denies every
deletion lane, including the flagged PowerShell mutation spellings, not only the Bash engine
apply. The kill-switch value reaches the guard as a runtime-substituted hook argument
(`--disk-hygiene-enabled ${user_config.disk_hygiene_enabled}`), so a configured `false` is
Expand Down
27 changes: 27 additions & 0 deletions plugins/disk-hygiene/skills/clean/scripts/destructive_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,23 @@ def is_exact_engine_apply(command: str, authority: str | None) -> bool:
return classify_exact_engine_command(command, authority) == "apply"


def is_exact_kill_switch_probe(command: str) -> bool:
"""Return True only for the exact, argument-free bundled probe invocation.

The probe (``skills/setup/scripts/kill_switch_probe.py``) is the
deterministic, report-only read of the ``disk_hygiene_enabled`` toggle; the
clean skill runs it when its body token arrives unexpanded. No arguments are
permitted, so the probed settings file is always the real default location.
"""
tokens = _literal_shell_words(command)
if tokens is None or len(tokens) != 2 or not _is_current_python(tokens[0]):
return False
expected_script = str(
Path(__file__).resolve().parents[2] / "setup" / "scripts" / "kill_switch_probe.py"
)
return _script_path_key(tokens[1]) == _script_path_key(expected_script)


_POWERSHELL_MUTATION_WORDS = re.compile(
r"(?i)(?<![\w./\\-])("
r"remove-item|rm|rmdir|del|erase|rd|ri|clear-content|rimraf|unlink"
Expand Down Expand Up @@ -392,6 +409,16 @@ def main() -> int:
return 0

authority = resolve_authorized_data_root()
if is_exact_kill_switch_probe(command):
print(
json.dumps(
decision(
"allow",
"Exact bundled disk-hygiene kill-switch probe (read-only report).",
)
)
)
return 0
command_kind = classify_exact_engine_command(command, authority)
if command_kind in {"scan", "preview"}:
print(
Expand Down
27 changes: 27 additions & 0 deletions plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py
Original file line number Diff line number Diff line change
Expand Up @@ -1405,6 +1405,33 @@ def test_guard_allows_only_exact_read_only_engine_shapes(self) -> None:
self.run_guard(malformed)["hookSpecificOutput"]["permissionDecision"],
)

def test_guard_allows_exact_kill_switch_probe_invocation(self) -> None:
probe = SCRIPT_DIR.parent.parent / "setup" / "scripts" / "kill_switch_probe.py"
command = f'"{self.python_command()}" "{probe}"'
result = self.run_guard(command)["hookSpecificOutput"]
self.assertEqual("allow", result["permissionDecision"])

def test_guard_allows_kill_switch_probe_in_audit_only_mode(self) -> None:
probe = SCRIPT_DIR.parent.parent / "setup" / "scripts" / "kill_switch_probe.py"
command = f'"{self.python_command()}" "{probe}"'
result = self.run_guard_disabled(command)["hookSpecificOutput"]
self.assertEqual("allow", result["permissionDecision"])

def test_guard_denies_kill_switch_probe_with_arguments(self) -> None:
probe = SCRIPT_DIR.parent.parent / "setup" / "scripts" / "kill_switch_probe.py"
for suffix in (" --settings-file s", " extra"):
command = f'"{self.python_command()}" "{probe}"{suffix}'
self.assertEqual(
"deny",
self.run_guard(command)["hookSpecificOutput"]["permissionDecision"],
command,
)

def test_guard_denies_kill_switch_probe_via_bare_python(self) -> None:
probe = SCRIPT_DIR.parent.parent / "setup" / "scripts" / "kill_switch_probe.py"
result = self.run_guard(f'python "{probe}"')["hookSpecificOutput"]
self.assertEqual("deny", result["permissionDecision"])

def test_guard_scan_accepts_optional_policy_and_project_dir(self) -> None:
script = SCRIPT_DIR / "hygiene.py"
base = f'"{self.python_command()}" "{script}" scan --target t --output s'
Expand Down
13 changes: 11 additions & 2 deletions plugins/disk-hygiene/skills/setup/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,17 @@ note that re-enabling restores the FAIL semantics.
`/proc/self/mountinfo` is readable; `lsof` needed only for the optional execution
lane — absent `lsof` is INFO with the reduced-capability note), macOS (audit/report
only by design — INFO, not a defect).
4. **Hook toggle** — report the effective `disk_hygiene_enabled` value:
`${user_config.disk_hygiene_enabled}` (unexpanded or empty means default `true`).
4. **Execution kill switch** — resolve the effective `disk_hygiene_enabled` value
deterministically; never present an assumed value as the configured one. Run the bundled
probe with the step-1 interpreter:
`"<python>" "${CLAUDE_PLUGIN_ROOT}/skills/setup/scripts/kill_switch_probe.py"`
and report its `effective` value together with its `source` (`configured` vs `default`).
When the probe says `degraded: true`, report that the configured value could not be read
and that default `true` is being assumed — an assumption, never the configured value. The
body token `${user_config.disk_hygiene_enabled}` is at most a cross-check: if it expanded
to a boolean that contradicts the probe, report the discrepancy instead of silently
preferring either channel (the probe sees user settings only; managed settings or a
`--settings` flag can carry a value the probe cannot see).
5. **Plugin registration** — INFO: confirm the plugin is enabled for this project
(`/plugin` → Installed) rather than parsing settings files.

Expand Down
204 changes: 204 additions & 0 deletions plugins/disk-hygiene/skills/setup/scripts/kill_switch_probe.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
#!/usr/bin/env python3
"""Deterministic read of the ``disk_hygiene_enabled`` kill switch.

``${user_config.*}`` body-token expansion in skill content is not reliable
enough to carry a safety report: an unexpanded token is indistinguishable from
"unset" and would present the assumed default as the configured value. This
probe reads the merged plugin options where Claude Code stores them —
``pluginConfigs[<plugin-id>].options`` in the user ``settings.json`` — and
reports the effective boolean with its provenance, degrading honestly when a
definitive read is impossible.

Report-only: exit code is always 0 and the single-line JSON on stdout is the
whole contract. Enforcement stays with ``destructive_guard.py``, which receives
the runtime-substituted ``--disk-hygiene-enabled`` hook argument.

Scope: managed settings and a ``--settings`` flag can also carry
``pluginConfigs`` and are not visible here; the ``detail`` sentence states the
path actually probed so the reader can judge the claim.
"""

from __future__ import annotations

import argparse
import json
import os
import stat
import sys
from pathlib import Path

_PLUGIN_NAME = "disk-hygiene"
_OPTION_KEY = "disk_hygiene_enabled"


def default_settings_path() -> Path:
config_dir = os.environ.get("CLAUDE_CONFIG_DIR")
base = Path(config_dir) if config_dir else Path.home() / ".claude"
return base / "settings.json"


def _matches_plugin(key: str) -> bool:
return key == _PLUGIN_NAME or key.startswith(f"{_PLUGIN_NAME}@")


def _interpret(value: object) -> bool | None:
"""Return the boolean meaning of a stored option value, or None if invalid."""
if isinstance(value, bool):
return value
if isinstance(value, str):
lowered = value.strip().lower()
if lowered == "true":
return True
if lowered == "false":
return False
return None


def _report(
effective: bool,
source: str,
degraded: bool,
detail: str,
settings_path: Path,
entries: list[dict[str, object]],
) -> dict[str, object]:
return {
"effective": effective,
"source": source,
"degraded": degraded,
"detail": detail,
"settings_path": str(settings_path),
"entries": entries,
}


def probe(settings_path: Path) -> dict[str, object]:
try:
settings_stat = settings_path.stat()
except FileNotFoundError:
return _report(
True,
"default",
False,
f"No settings file at {settings_path}; the toggle is not configured "
"there and the plugin default (enabled) applies. Managed settings or "
"a --settings flag could still carry a value this probe cannot see.",
settings_path,
[],
)
except OSError as exc:
return _report(
True,
"indeterminate",
True,
f"Could not inspect {settings_path} ({exc}); assuming the default "
"(enabled). This is an assumption, not the configured value.",
settings_path,
[],
)
if not stat.S_ISREG(settings_stat.st_mode):
return _report(
True,
"indeterminate",
True,
f"{settings_path} exists but is not a regular file; assuming the "
"default (enabled). This is an assumption, not the configured value.",
settings_path,
[],
)
try:
settings = json.loads(settings_path.read_text(encoding="utf-8"))
if not isinstance(settings, dict):
raise ValueError("settings root is not a JSON object")
except (OSError, UnicodeDecodeError, ValueError) as exc:
return _report(
True,
"indeterminate",
True,
f"Could not read the configured toggle from {settings_path} ({exc}); "
"assuming the default (enabled). This is an assumption, not the "
"configured value.",
settings_path,
[],
)

plugin_configs = settings.get("pluginConfigs")
Comment thread
kyle-sexton marked this conversation as resolved.
entries: list[dict[str, object]] = []
if isinstance(plugin_configs, dict):
for key in sorted(plugin_configs):
Comment thread
kyle-sexton marked this conversation as resolved.
if not _matches_plugin(key):
continue
entry = plugin_configs.get(key)
options = entry.get("options") if isinstance(entry, dict) else None
if not isinstance(options, dict) or _OPTION_KEY not in options:
continue
entries.append({"key": key, "value": options[_OPTION_KEY]})

if not entries:
return _report(
True,
"default",
False,
f"{settings_path} carries no {_PLUGIN_NAME} {_OPTION_KEY} entry; the "
"toggle is not configured there and the plugin default (enabled) "
"applies. Managed settings or a --settings flag could still carry a "
"value this probe cannot see.",
settings_path,
entries,
)

interpreted = {entry["key"]: _interpret(entry["value"]) for entry in entries}
values = set(interpreted.values())
if None in values:
return _report(
True,
"indeterminate",
True,
"A configured value is not a recognizable boolean "
f"({json.dumps({k: e['value'] for k, e in zip(interpreted, entries)})}); "
"assuming the default (enabled). This is an assumption, not the "
"configured value.",
settings_path,
entries,
)
if len(values) > 1:
return _report(
True,
"indeterminate",
True,
"Multiple disk-hygiene entries disagree on the toggle "
f"({json.dumps(interpreted)}); assuming the default (enabled). This "
"is an assumption, not the configured value.",
settings_path,
entries,
)
effective = values.pop()
assert effective is not None
return _report(
effective,
"configured",
False,
f"{_OPTION_KEY} is configured {str(effective).lower()} in "
f"{settings_path}.",
settings_path,
entries,
)


def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--settings-file",
help="settings.json to probe (default: $CLAUDE_CONFIG_DIR/settings.json "
"or ~/.claude/settings.json)",
)
args = parser.parse_args(argv)
settings_path = (
Path(args.settings_file) if args.settings_file else default_settings_path()
)
print(json.dumps(probe(settings_path)))
return 0


if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# Cross-platform contract wrapper for the kill-switch probe test suite.
set -euo pipefail

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

if command -v python >/dev/null 2>&1; then
PYTHON=python
elif command -v python3 >/dev/null 2>&1; then
PYTHON=python3
else
echo "SKIP: Python 3.11+ not found" >&2
exit 0
fi

"$PYTHON" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 11) else 1)' || {
echo "SKIP: Python 3.11+ required" >&2
exit 0
}
"$PYTHON" -m unittest -v "$SCRIPT_DIR/test_kill_switch_probe.py"
Loading