From ff8c8e13989e08bee20b63bfb2cb5a0cdef23246 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:58:56 -0400 Subject: [PATCH 1/4] fix(disk-hygiene): report the kill switch deterministically, never from an unexpanded body token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setup check step 4 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 one safety-critical setting the check exists to verify. The clean skill's audit-only instruction carried the same "unset = enabled" assumption. Add a report-only, stdlib-only probe (skills/setup/scripts/kill_switch_probe.py) that reads pluginConfigs[].options.disk_hygiene_enabled from the user settings.json (CLAUDE_CONFIG_DIR-aware) and emits one JSON line with the effective boolean, its provenance (configured/default/indeterminate), a degraded flag, and the matched entries. Unparsable settings, invalid value types, and conflicting entries degrade honestly — assumed default true is always labeled an assumption, never the configured value. setup check now reports the probe result with provenance and treats the body token as at most a cross-check whose contradiction is reported. clean resolves an unexpanded token through the same probe; the guard's Bash allowlist permits exactly the argument-free bundled probe shape (any argument, bare python, or another path stays denied). Enforcement remains the guard's runtime-substituted --disk-hygiene-enabled hook argument (0.4.4). Docs verified this session: plugins-reference documents skill-content substitution for non-sensitive user_config values, but a live run observed the token unexpanded, so body expansion cannot be load-bearing for a safety report. Closes #1007 Co-Authored-By: Claude Fable 5 --- .../disk-hygiene/.claude-plugin/plugin.json | 2 +- plugins/disk-hygiene/CHANGELOG.md | 28 +++ plugins/disk-hygiene/skills/clean/SKILL.md | 8 +- .../skills/clean/scripts/destructive_guard.py | 27 +++ .../skills/clean/scripts/test_hygiene.py | 27 +++ plugins/disk-hygiene/skills/setup/SKILL.md | 13 +- .../skills/setup/scripts/kill_switch_probe.py | 177 ++++++++++++++ .../setup/scripts/kill_switch_probe.test.sh | 20 ++ .../setup/scripts/test_kill_switch_probe.py | 223 ++++++++++++++++++ 9 files changed, 520 insertions(+), 5 deletions(-) create mode 100644 plugins/disk-hygiene/skills/setup/scripts/kill_switch_probe.py create mode 100755 plugins/disk-hygiene/skills/setup/scripts/kill_switch_probe.test.sh create mode 100644 plugins/disk-hygiene/skills/setup/scripts/test_kill_switch_probe.py diff --git a/plugins/disk-hygiene/.claude-plugin/plugin.json b/plugins/disk-hygiene/.claude-plugin/plugin.json index 65ae83cca..61ca9eb58 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.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", diff --git a/plugins/disk-hygiene/CHANGELOG.md b/plugins/disk-hygiene/CHANGELOG.md index ede016e39..512fdd2ac 100644 --- a/plugins/disk-hygiene/CHANGELOG.md +++ b/plugins/disk-hygiene/CHANGELOG.md @@ -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[].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 diff --git a/plugins/disk-hygiene/skills/clean/SKILL.md b/plugins/disk-hygiene/skills/clean/SKILL.md index e68dc6ab5..babbbaae0 100644 --- a/plugins/disk-hygiene/skills/clean/SKILL.md +++ b/plugins/disk-hygiene/skills/clean/SKILL.md @@ -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): + `"" "${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 diff --git a/plugins/disk-hygiene/skills/clean/scripts/destructive_guard.py b/plugins/disk-hygiene/skills/clean/scripts/destructive_guard.py index 875b4f0ef..1a9a47120 100755 --- a/plugins/disk-hygiene/skills/clean/scripts/destructive_guard.py +++ b/plugins/disk-hygiene/skills/clean/scripts/destructive_guard.py @@ -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)(? 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( diff --git a/plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py b/plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py index a6df2848e..2fd00ac3f 100755 --- a/plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py +++ b/plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py @@ -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' diff --git a/plugins/disk-hygiene/skills/setup/SKILL.md b/plugins/disk-hygiene/skills/setup/SKILL.md index 980e40c72..c2e9a0b33 100644 --- a/plugins/disk-hygiene/skills/setup/SKILL.md +++ b/plugins/disk-hygiene/skills/setup/SKILL.md @@ -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: + `"" "${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. diff --git a/plugins/disk-hygiene/skills/setup/scripts/kill_switch_probe.py b/plugins/disk-hygiene/skills/setup/scripts/kill_switch_probe.py new file mode 100644 index 000000000..e708f9536 --- /dev/null +++ b/plugins/disk-hygiene/skills/setup/scripts/kill_switch_probe.py @@ -0,0 +1,177 @@ +#!/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[].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 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]: + if not settings_path.is_file(): + return _report( + True, + "default", + False, + f"No settings file at {settings_path}; the toggle is not configured " + "there and the plugin default (enabled) applies.", + settings_path, + [], + ) + try: + settings = json.loads(settings_path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) 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") + entries: list[dict[str, object]] = [] + if isinstance(plugin_configs, dict): + for key in sorted(plugin_configs): + 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.", + 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:])) diff --git a/plugins/disk-hygiene/skills/setup/scripts/kill_switch_probe.test.sh b/plugins/disk-hygiene/skills/setup/scripts/kill_switch_probe.test.sh new file mode 100755 index 000000000..cb470a43d --- /dev/null +++ b/plugins/disk-hygiene/skills/setup/scripts/kill_switch_probe.test.sh @@ -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" diff --git a/plugins/disk-hygiene/skills/setup/scripts/test_kill_switch_probe.py b/plugins/disk-hygiene/skills/setup/scripts/test_kill_switch_probe.py new file mode 100644 index 000000000..667c6f554 --- /dev/null +++ b/plugins/disk-hygiene/skills/setup/scripts/test_kill_switch_probe.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python3 +"""Behavioral tests for the deterministic kill-switch probe. + +The probe exists so ``/disk-hygiene:setup check`` (and the clean skill when its +body token arrives unexpanded) can report the *actually configured* +``disk_hygiene_enabled`` value instead of presenting an assumed default as the +configured one. Every degraded read must be labeled as assumed, never as +configured. +""" + +from __future__ import annotations + +import importlib.util +import io +import json +import tempfile +import unittest +from contextlib import redirect_stdout +from pathlib import Path +from unittest import mock + +SCRIPT_DIR = Path(__file__).resolve().parent + + +def load_module(name: str, filename: str): + spec = importlib.util.spec_from_file_location(name, SCRIPT_DIR / filename) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +probe = load_module("kill_switch_probe", "kill_switch_probe.py") + + +class ProbeTests(unittest.TestCase): + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.settings = Path(self.tmp.name) / "settings.json" + + def write_settings(self, payload: object) -> None: + self.settings.write_text(json.dumps(payload), encoding="utf-8") + + def run_probe(self, argv: list[str] | None = None) -> dict[str, object]: + if argv is None: + argv = ["--settings-file", str(self.settings)] + stdout = io.StringIO() + with redirect_stdout(stdout): + self.assertEqual(0, probe.main(argv)) + return json.loads(stdout.getvalue()) + + def test_missing_settings_file_reports_default_not_degraded(self) -> None: + result = self.run_probe( + ["--settings-file", str(Path(self.tmp.name) / "absent.json")] + ) + self.assertTrue(result["effective"]) + self.assertEqual("default", result["source"]) + self.assertFalse(result["degraded"]) + + def test_configured_false_reported_with_provenance(self) -> None: + self.write_settings( + { + "pluginConfigs": { + "disk-hygiene@melodic-software": { + "options": {"disk_hygiene_enabled": False} + } + } + } + ) + result = self.run_probe() + self.assertFalse(result["effective"]) + self.assertEqual("configured", result["source"]) + self.assertFalse(result["degraded"]) + self.assertEqual( + ["disk-hygiene@melodic-software"], + [entry["key"] for entry in result["entries"]], + ) + + def test_configured_true_reported_as_configured(self) -> None: + self.write_settings( + { + "pluginConfigs": { + "disk-hygiene@melodic-software": { + "options": {"disk_hygiene_enabled": True} + } + } + } + ) + result = self.run_probe() + self.assertTrue(result["effective"]) + self.assertEqual("configured", result["source"]) + + def test_bare_plugin_key_without_marketplace_suffix_matches(self) -> None: + self.write_settings( + {"pluginConfigs": {"disk-hygiene": {"options": {"disk_hygiene_enabled": False}}}} + ) + result = self.run_probe() + self.assertFalse(result["effective"]) + self.assertEqual("configured", result["source"]) + + def test_unrelated_plugin_entry_does_not_match(self) -> None: + self.write_settings( + { + "pluginConfigs": { + "disk-hygiene-extras@other": { + "options": {"disk_hygiene_enabled": False} + }, + "other-plugin@m": {"options": {"disk_hygiene_enabled": False}}, + } + } + ) + result = self.run_probe() + self.assertTrue(result["effective"]) + self.assertEqual("default", result["source"]) + self.assertFalse(result["degraded"]) + + def test_matched_entry_without_toggle_reports_default(self) -> None: + self.write_settings( + {"pluginConfigs": {"disk-hygiene@melodic-software": {"options": {}}}} + ) + result = self.run_probe() + self.assertTrue(result["effective"]) + self.assertEqual("default", result["source"]) + self.assertFalse(result["degraded"]) + + def test_string_boolean_values_accepted(self) -> None: + self.write_settings( + { + "pluginConfigs": { + "disk-hygiene@melodic-software": { + "options": {"disk_hygiene_enabled": "False"} + } + } + } + ) + result = self.run_probe() + self.assertFalse(result["effective"]) + self.assertEqual("configured", result["source"]) + + def test_unparsable_settings_degrade_honestly(self) -> None: + self.settings.write_text("{not json", encoding="utf-8") + result = self.run_probe() + self.assertTrue(result["effective"]) + self.assertEqual("indeterminate", result["source"]) + self.assertTrue(result["degraded"]) + self.assertIn("assuming", result["detail"].lower()) + + def test_invalid_value_type_degrades(self) -> None: + self.write_settings( + { + "pluginConfigs": { + "disk-hygiene@melodic-software": { + "options": {"disk_hygiene_enabled": 42} + } + } + } + ) + result = self.run_probe() + self.assertTrue(result["effective"]) + self.assertEqual("indeterminate", result["source"]) + self.assertTrue(result["degraded"]) + + def test_conflicting_entries_degrade_and_fail_safe_to_enabled(self) -> None: + self.write_settings( + { + "pluginConfigs": { + "disk-hygiene@a": {"options": {"disk_hygiene_enabled": False}}, + "disk-hygiene@b": {"options": {"disk_hygiene_enabled": True}}, + } + } + ) + result = self.run_probe() + self.assertTrue(result["effective"]) + self.assertEqual("indeterminate", result["source"]) + self.assertTrue(result["degraded"]) + self.assertEqual(2, len(result["entries"])) + + def test_consistent_duplicate_entries_stay_configured(self) -> None: + self.write_settings( + { + "pluginConfigs": { + "disk-hygiene@a": {"options": {"disk_hygiene_enabled": False}}, + "disk-hygiene@b": {"options": {"disk_hygiene_enabled": False}}, + } + } + ) + result = self.run_probe() + self.assertFalse(result["effective"]) + self.assertEqual("configured", result["source"]) + self.assertFalse(result["degraded"]) + + def test_default_path_honors_claude_config_dir(self) -> None: + self.write_settings( + { + "pluginConfigs": { + "disk-hygiene@melodic-software": { + "options": {"disk_hygiene_enabled": False} + } + } + } + ) + with mock.patch.dict( + "os.environ", {"CLAUDE_CONFIG_DIR": self.tmp.name}, clear=False + ): + result = self.run_probe([]) + self.assertFalse(result["effective"]) + self.assertEqual("configured", result["source"]) + self.assertEqual(str(self.settings), result["settings_path"]) + + def test_output_is_single_line_json(self) -> None: + stdout = io.StringIO() + with redirect_stdout(stdout): + self.assertEqual( + 0, probe.main(["--settings-file", str(self.settings)]) + ) + text = stdout.getvalue() + self.assertEqual(1, len([line for line in text.splitlines() if line])) + json.loads(text) + + +if __name__ == "__main__": + unittest.main() From 7cc6d57b87e0ed077e6d40a685c7fa156d9236bb Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:12:46 -0400 Subject: [PATCH 2/4] fix(disk-hygiene): mark shebanged probe scripts executable in the git index CI's shebang-executable gate requires 100755 for files with a shebang. Co-Authored-By: Claude Fable 5 --- plugins/disk-hygiene/skills/setup/scripts/kill_switch_probe.py | 0 .../disk-hygiene/skills/setup/scripts/test_kill_switch_probe.py | 0 2 files changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 plugins/disk-hygiene/skills/setup/scripts/kill_switch_probe.py mode change 100644 => 100755 plugins/disk-hygiene/skills/setup/scripts/test_kill_switch_probe.py diff --git a/plugins/disk-hygiene/skills/setup/scripts/kill_switch_probe.py b/plugins/disk-hygiene/skills/setup/scripts/kill_switch_probe.py old mode 100644 new mode 100755 diff --git a/plugins/disk-hygiene/skills/setup/scripts/test_kill_switch_probe.py b/plugins/disk-hygiene/skills/setup/scripts/test_kill_switch_probe.py old mode 100644 new mode 100755 From e0cab40f533afd876d614c354e68d8174e39459e Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:25:28 -0400 Subject: [PATCH 3/4] fix(disk-hygiene): probe survives non-object settings roots and names its user-settings-only scope A settings.json whose root is valid JSON but not an object (e.g. []) hit .get() and crashed the probe with AttributeError, breaking the promised single-line-JSON contract; it now degrades to indeterminate like any other unreadable settings document. Default-source reports also state in the report itself that managed settings or a --settings flag could carry a value the probe cannot see, instead of leaving that caveat to the skill prose alone. Co-Authored-By: Claude Fable 5 --- .../skills/setup/scripts/kill_switch_probe.py | 10 ++++++--- .../setup/scripts/test_kill_switch_probe.py | 22 +++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/plugins/disk-hygiene/skills/setup/scripts/kill_switch_probe.py b/plugins/disk-hygiene/skills/setup/scripts/kill_switch_probe.py index e708f9536..d95aea6d1 100755 --- a/plugins/disk-hygiene/skills/setup/scripts/kill_switch_probe.py +++ b/plugins/disk-hygiene/skills/setup/scripts/kill_switch_probe.py @@ -78,13 +78,16 @@ def probe(settings_path: Path) -> dict[str, object]: "default", False, f"No settings file at {settings_path}; the toggle is not configured " - "there and the plugin default (enabled) applies.", + "there and the plugin default (enabled) applies. Managed settings or " + "a --settings flag could still carry a value this probe cannot see.", settings_path, [], ) try: settings = json.loads(settings_path.read_text(encoding="utf-8")) - except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: + if not isinstance(settings, dict): + raise ValueError("settings root is not a JSON object") + except (OSError, UnicodeDecodeError, ValueError) as exc: return _report( True, "indeterminate", @@ -115,7 +118,8 @@ def probe(settings_path: Path) -> dict[str, object]: False, f"{settings_path} carries no {_PLUGIN_NAME} {_OPTION_KEY} entry; the " "toggle is not configured there and the plugin default (enabled) " - "applies.", + "applies. Managed settings or a --settings flag could still carry a " + "value this probe cannot see.", settings_path, entries, ) diff --git a/plugins/disk-hygiene/skills/setup/scripts/test_kill_switch_probe.py b/plugins/disk-hygiene/skills/setup/scripts/test_kill_switch_probe.py index 667c6f554..d17565cf9 100755 --- a/plugins/disk-hygiene/skills/setup/scripts/test_kill_switch_probe.py +++ b/plugins/disk-hygiene/skills/setup/scripts/test_kill_switch_probe.py @@ -138,6 +138,28 @@ def test_string_boolean_values_accepted(self) -> None: self.assertFalse(result["effective"]) self.assertEqual("configured", result["source"]) + def test_non_object_settings_root_degrades_without_crashing(self) -> None: + self.settings.write_text("[]", encoding="utf-8") + result = self.run_probe() + self.assertTrue(result["effective"]) + self.assertEqual("indeterminate", result["source"]) + self.assertTrue(result["degraded"]) + + def test_default_reports_name_their_user_settings_only_scope(self) -> None: + for setup in ("missing", "no-entry"): + with self.subTest(setup): + if setup == "missing": + argv = [ + "--settings-file", + str(Path(self.tmp.name) / "absent.json"), + ] + else: + self.write_settings({"pluginConfigs": {}}) + argv = None + result = self.run_probe(argv) + self.assertEqual("default", result["source"]) + self.assertIn("cannot see", result["detail"]) + def test_unparsable_settings_degrade_honestly(self) -> None: self.settings.write_text("{not json", encoding="utf-8") result = self.run_probe() From 3eb21d7e1a4e5b8c7aa76d242d64def2ccaab3b4 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:32:22 -0400 Subject: [PATCH 4/4] fix(disk-hygiene): probe degrades when the settings path cannot be inspected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Path.is_file() swallows OSError and returns False for existing non-files, so an inaccessible settings.json (unsearchable ancestor) or a directory at the settings path was reported as "no settings file, default, not degraded" — a certainty the probe did not have. The probe now stats the path itself: only FileNotFoundError keeps the clean default report; any other OSError or a non-regular file degrades to indeterminate. Co-Authored-By: Claude Fable 5 --- .../skills/setup/scripts/kill_switch_probe.py | 25 ++++++++++++++++++- .../setup/scripts/test_kill_switch_probe.py | 17 +++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/plugins/disk-hygiene/skills/setup/scripts/kill_switch_probe.py b/plugins/disk-hygiene/skills/setup/scripts/kill_switch_probe.py index d95aea6d1..41ad4f20b 100755 --- a/plugins/disk-hygiene/skills/setup/scripts/kill_switch_probe.py +++ b/plugins/disk-hygiene/skills/setup/scripts/kill_switch_probe.py @@ -23,6 +23,7 @@ import argparse import json import os +import stat import sys from pathlib import Path @@ -72,7 +73,9 @@ def _report( def probe(settings_path: Path) -> dict[str, object]: - if not settings_path.is_file(): + try: + settings_stat = settings_path.stat() + except FileNotFoundError: return _report( True, "default", @@ -83,6 +86,26 @@ def probe(settings_path: Path) -> dict[str, object]: 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): diff --git a/plugins/disk-hygiene/skills/setup/scripts/test_kill_switch_probe.py b/plugins/disk-hygiene/skills/setup/scripts/test_kill_switch_probe.py index d17565cf9..34ce72f3c 100755 --- a/plugins/disk-hygiene/skills/setup/scripts/test_kill_switch_probe.py +++ b/plugins/disk-hygiene/skills/setup/scripts/test_kill_switch_probe.py @@ -138,6 +138,23 @@ def test_string_boolean_values_accepted(self) -> None: self.assertFalse(result["effective"]) self.assertEqual("configured", result["source"]) + def test_directory_at_settings_path_degrades(self) -> None: + directory = Path(self.tmp.name) / "settings-as-dir" + directory.mkdir() + result = self.run_probe(["--settings-file", str(directory)]) + self.assertTrue(result["effective"]) + self.assertEqual("indeterminate", result["source"]) + self.assertTrue(result["degraded"]) + + def test_uninspectable_settings_path_degrades(self) -> None: + with mock.patch.object( + probe.Path, "stat", side_effect=PermissionError("denied") + ): + result = self.run_probe() + self.assertTrue(result["effective"]) + self.assertEqual("indeterminate", result["source"]) + self.assertTrue(result["degraded"]) + def test_non_object_settings_root_degrades_without_crashing(self) -> None: self.settings.write_text("[]", encoding="utf-8") result = self.run_probe()