From bdb728229ebdb0d83684ebadde09da728a74e586 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:58:25 -0400 Subject: [PATCH 01/15] feat(disk-hygiene): add managed-state owner registry as data with schema and test Refs #4006 Co-Authored-By: Claude Opus 5.5 --- .../clean/reference/owner-registry.json | 87 +++++++++++++ .../reference/owner-registry.schema.json | 46 +++++++ .../clean/scripts/owner_registry.test.sh | 33 +++++ .../clean/scripts/test_owner_registry.py | 121 ++++++++++++++++++ 4 files changed, 287 insertions(+) create mode 100644 plugins/disk-hygiene/skills/clean/reference/owner-registry.json create mode 100644 plugins/disk-hygiene/skills/clean/reference/owner-registry.schema.json create mode 100755 plugins/disk-hygiene/skills/clean/scripts/owner_registry.test.sh create mode 100644 plugins/disk-hygiene/skills/clean/scripts/test_owner_registry.py diff --git a/plugins/disk-hygiene/skills/clean/reference/owner-registry.json b/plugins/disk-hygiene/skills/clean/reference/owner-registry.json new file mode 100644 index 0000000000..2c74b74dc9 --- /dev/null +++ b/plugins/disk-hygiene/skills/clean/reference/owner-registry.json @@ -0,0 +1,87 @@ +{ + "version": 1, + "note": "An entry is a hint for an owner claim, never authorization. A path matching an entry is a starting point for proving the owning product manages it, not proof. Commands are data for an operator to read; nothing here runs them. Paths are relative to the home directory. A null command means the product has none; manual_step names the in-app or settings action instead.", + "entries": [ + { + "id": "docker-desktop", + "owner": "Docker Desktop", + "path_patterns": { + "windows": ["AppData/Local/Docker"], + "macos": ["Library/Containers/com.docker.docker"] + }, + "tool": "docker", + "read_only_command": "docker system df", + "destructive_native_command": "docker image prune; docker builder prune", + "manual_step": null, + "platforms": ["windows", "macos"] + }, + { + "id": "nvidia-shader-cache", + "owner": "NVIDIA driver shader cache", + "path_patterns": { + "windows": ["AppData/Local/NVIDIA"] + }, + "tool": "nvidia-smi", + "read_only_command": null, + "destructive_native_command": null, + "manual_step": "Manual step in Windows Disk Cleanup: select DirectX Shader Cache", + "platforms": ["windows"] + }, + { + "id": "cursor", + "owner": "Cursor", + "path_patterns": { + "windows": ["AppData/Roaming/Cursor"], + "macos": ["Library/Application Support/Cursor"], + "linux": [".config/Cursor"] + }, + "tool": "cursor", + "read_only_command": null, + "destructive_native_command": null, + "manual_step": "In-app cache clearing in Cursor", + "platforms": ["windows", "macos", "linux"] + }, + { + "id": "openai-codex-cli", + "owner": "OpenAI Codex CLI", + "path_patterns": { + "windows": [".codex"], + "macos": [".codex"], + "linux": [".codex"] + }, + "tool": "codex", + "read_only_command": null, + "destructive_native_command": null, + "manual_step": "Adjust Codex history retention in its configuration", + "platforms": ["windows", "macos", "linux"] + }, + { + "id": "chezmoi", + "owner": "chezmoi", + "path_patterns": { + "windows": [".cache/chezmoi"], + "macos": [".cache/chezmoi"], + "linux": [".cache/chezmoi"] + }, + "tool": "chezmoi", + "read_only_command": "chezmoi doctor", + "destructive_native_command": null, + "manual_step": null, + "platforms": ["windows", "macos", "linux"] + }, + { + "id": "pulumi", + "owner": "Pulumi", + "path_patterns": { + "windows": [".pulumi"], + "macos": [".pulumi"], + "linux": [".pulumi"] + }, + "tool": "pulumi", + "read_only_command": "pulumi about; pulumi plugin ls", + "destructive_native_command": "pulumi plugin rm ", + "manual_step": null, + "platforms": ["windows", "macos", "linux"] + } + ] +} diff --git a/plugins/disk-hygiene/skills/clean/reference/owner-registry.schema.json b/plugins/disk-hygiene/skills/clean/reference/owner-registry.schema.json new file mode 100644 index 0000000000..9932b60389 --- /dev/null +++ b/plugins/disk-hygiene/skills/clean/reference/owner-registry.schema.json @@ -0,0 +1,46 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": ["version", "note", "entries"], + "additionalProperties": false, + "properties": { + "version": {"const": 1}, + "note": {"type": "string", "minLength": 1}, + "entries": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": [ + "id", "owner", "path_patterns", "tool", "read_only_command", + "destructive_native_command", "manual_step", "platforms" + ], + "additionalProperties": false, + "properties": { + "id": {"type": "string", "minLength": 1}, + "owner": {"type": "string", "minLength": 1}, + "path_patterns": { + "type": "object", + "minProperties": 1, + "propertyNames": {"enum": ["windows", "macos", "linux"]}, + "additionalProperties": { + "type": "array", + "minItems": 1, + "items": {"type": "string", "minLength": 1} + } + }, + "tool": {"type": "string", "minLength": 1}, + "read_only_command": {"type": ["string", "null"]}, + "destructive_native_command": {"type": ["string", "null"]}, + "manual_step": {"type": ["string", "null"]}, + "platforms": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": {"enum": ["windows", "macos", "linux"]} + } + } + } + } + } +} diff --git a/plugins/disk-hygiene/skills/clean/scripts/owner_registry.test.sh b/plugins/disk-hygiene/skills/clean/scripts/owner_registry.test.sh new file mode 100755 index 0000000000..bfd30bb1e4 --- /dev/null +++ b/plugins/disk-hygiene/skills/clean/scripts/owner_registry.test.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Cross-platform contract wrapper for the owner-registry 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 +} +PYFILE="" +test_wrapper::python_file_to PYFILE "$SCRIPT_DIR/test_owner_registry.py" +"$PYTHON" -m unittest -v "$PYFILE" diff --git a/plugins/disk-hygiene/skills/clean/scripts/test_owner_registry.py b/plugins/disk-hygiene/skills/clean/scripts/test_owner_registry.py new file mode 100644 index 0000000000..da249f89da --- /dev/null +++ b/plugins/disk-hygiene/skills/clean/scripts/test_owner_registry.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""Tests for the bundled managed-state owner registry.""" + +from __future__ import annotations + +import fnmatch +import json +import unittest +from pathlib import Path +from typing import Any + +REFERENCE = Path(__file__).resolve().parents[1] / "reference" +REGISTRY = json.loads((REFERENCE / "owner-registry.json").read_text(encoding="utf-8")) +SCHEMA = json.loads( + (REFERENCE / "owner-registry.schema.json").read_text(encoding="utf-8") +) +POLICY = json.loads((REFERENCE / "baseline-policy.json").read_text(encoding="utf-8")) + +EXPECTED_IDS = { + "docker-desktop", + "nvidia-shader-cache", + "cursor", + "openai-codex-cli", + "chezmoi", + "pulumi", +} +JSON_TYPES = { + "object": dict, + "array": list, + "string": str, + "null": type(None), +} + + +def validate(value: Any, schema: dict[str, Any], where: str = "$") -> list[str]: + """Check the JSON Schema keywords the registry schema uses; return errors.""" + errors: list[str] = [] + if "const" in schema and value != schema["const"]: + errors.append(f"{where}: expected {schema['const']!r}") + if "enum" in schema and value not in schema["enum"]: + errors.append(f"{where}: {value!r} not in {schema['enum']}") + if "type" in schema: + names = schema["type"] if isinstance(schema["type"], list) else [schema["type"]] + if not any(isinstance(value, JSON_TYPES[name]) for name in names): + return [*errors, f"{where}: expected type {names}"] + if isinstance(value, str) and len(value) < schema.get("minLength", 0): + errors.append(f"{where}: shorter than {schema['minLength']}") + if isinstance(value, list): + if len(value) < schema.get("minItems", 0): + errors.append(f"{where}: fewer than {schema['minItems']} items") + if schema.get("uniqueItems") and len({json.dumps(v) for v in value}) != len( + value + ): + errors.append(f"{where}: items not unique") + for index, item in enumerate(value): + errors += validate(item, schema.get("items", {}), f"{where}[{index}]") + if isinstance(value, dict): + if len(value) < schema.get("minProperties", 0): + errors.append(f"{where}: fewer than {schema['minProperties']} properties") + for key in schema.get("required", []): + if key not in value: + errors.append(f"{where}: missing {key!r}") + properties = schema.get("properties", {}) + extra = schema.get("additionalProperties", True) + for key, item in value.items(): + if "propertyNames" in schema: + errors += validate(key, schema["propertyNames"], f"{where}.<{key}>") + if key in properties: + errors += validate(item, properties[key], f"{where}.{key}") + elif extra is False: + errors.append(f"{where}: unexpected {key!r}") + elif isinstance(extra, dict): + errors += validate(item, extra, f"{where}.{key}") + return errors + + +def pattern_segments(entry: dict[str, Any]) -> list[str]: + return [ + segment + for patterns in entry["path_patterns"].values() + for pattern in patterns + for segment in pattern.replace("\\", "/").split("/") + ] + + +class OwnerRegistryTest(unittest.TestCase): + def test_registry_validates_against_schema(self) -> None: + self.assertEqual(validate(REGISTRY, SCHEMA), []) + + def test_validator_rejects_a_malformed_entry(self) -> None: + bad = json.loads(json.dumps(REGISTRY)) + del bad["entries"][0]["tool"] + bad["entries"][1]["platforms"] = ["beos"] + self.assertEqual(len(validate(bad, SCHEMA)), 2) + + def test_seeds_the_six_owners(self) -> None: + ids = [entry["id"] for entry in REGISTRY["entries"]] + self.assertEqual(len(ids), len(set(ids))) + self.assertEqual(set(ids), EXPECTED_IDS) + + def test_note_states_hint_is_not_authorization(self) -> None: + self.assertIn("never authorization", REGISTRY["note"]) + + def test_path_patterns_cover_only_declared_platforms(self) -> None: + for entry in REGISTRY["entries"]: + self.assertLessEqual( + set(entry["path_patterns"]), set(entry["platforms"]), entry["id"] + ) + + def test_no_path_pattern_touches_a_protected_name(self) -> None: + exact = {name.casefold() for name in POLICY["protected_exact_names"]} + globs = POLICY["protected_name_globs"] + for entry in REGISTRY["entries"]: + for segment in pattern_segments(entry): + self.assertNotIn(segment.casefold(), exact, entry["id"]) + for glob in globs: + self.assertFalse(fnmatch.fnmatchcase(segment, glob), entry["id"]) + + +if __name__ == "__main__": + unittest.main() From e1b3b9349cd1eb1f39e28e7cdead9c11fd6634fb Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:59:33 -0400 Subject: [PATCH 02/15] docs(disk-hygiene): specify the managed-state report for registry matches Refs #4006 Co-Authored-By: Claude Opus 5.5 --- plugins/disk-hygiene/skills/clean/SKILL.md | 3 ++ .../clean/reference/managed-state-report.md | 37 +++++++++++++++++++ .../skills/clean/reference/safety-model.md | 3 +- 3 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 plugins/disk-hygiene/skills/clean/reference/managed-state-report.md diff --git a/plugins/disk-hygiene/skills/clean/SKILL.md b/plugins/disk-hygiene/skills/clean/SKILL.md index 2ef2d309ad..abf32ec32e 100644 --- a/plugins/disk-hygiene/skills/clean/SKILL.md +++ b/plugins/disk-hygiene/skills/clean/SKILL.md @@ -181,6 +181,9 @@ The guard validates `--data-root` against the plugin data directory it derives i the call outright when it cannot recognize the install layout, so a run reporting that denial is a coverage gap, not a clean result. (Derivation and its fail-closed rationale: `reference/safety-model.md`.) +Managed-state registry matches are reported per +[managed-state-report.md](reference/managed-state-report.md). + For a large root (a home directory, anything whose recursive walk could exceed the engine's entry cap), start with a bounded pass: add `--max-depth 1` to inventory the target's loose files and immediate children, then fan out deeper scans per subtree that the evidence justifies. After that depth-1 pass, re-inventory diff --git a/plugins/disk-hygiene/skills/clean/reference/managed-state-report.md b/plugins/disk-hygiene/skills/clean/reference/managed-state-report.md new file mode 100644 index 0000000000..470e3160bf --- /dev/null +++ b/plugins/disk-hygiene/skills/clean/reference/managed-state-report.md @@ -0,0 +1,37 @@ +# Managed-state report + +The report a managed-state registry match produces. The registry is +[owner-registry.json](owner-registry.json), validated by +[owner-registry.schema.json](owner-registry.schema.json). The engine's eligibility rules for +managed state stay as [the safety model](safety-model.md) states them. + +## Report per registry match + +1. **Owner.** The entry's `owner` and `id`, with the matched path. The match is a hint for an + owner claim, not proof of one. +2. **Tool presence.** Resolve the entry's `tool` on PATH before anything else. Absent: status + `absent-tool`, and the report offers no command of any kind, including the manual step. +3. **Read-only command.** When present and `read_only_command` is set, run it and capture its + output into the report verbatim. A null command means the product has none; the report shows + `manual_step` as information. +4. **Destructive native command.** Shown as information only, never run by the report. Running it + is a separate act routed through the engine's existing approval: the tier, the exact-path list + and a fresh preview. This lane has no gate of its own. +5. **Unmatched paths.** A managed-looking path with no registry match is reported as a coverage + gap. It is never `clean` and never removable. + +## Design check + +| #4006 design constraint | How this report meets it | +|---|---| +| Containment is untouched | The report adds no deletion capability; engine eligibility is unchanged. | +| Read-only and destructive are different gates | Step 3 runs freely; step 4 is information routed to the engine's approval. | +| Tool presence is checked first | Step 2 precedes every command; absent gives `absent-tool` and no commands. | +| An entry is a hint, never authorization | Step 1 treats a match as a claim to prove. | +| Unmatched stays a coverage gap | Step 5. | +| The registry is inspectable | Plain JSON plus a schema, readable without running anything. | + +## Scope + +No destructive command is built. Whether a product-native destructive command is ever run stays +the owner's decision (#4006). diff --git a/plugins/disk-hygiene/skills/clean/reference/safety-model.md b/plugins/disk-hygiene/skills/clean/reference/safety-model.md index cce0b88cb2..fbcc383bda 100644 --- a/plugins/disk-hygiene/skills/clean/reference/safety-model.md +++ b/plugins/disk-hygiene/skills/clean/reference/safety-model.md @@ -666,7 +666,8 @@ and has no entry cap. Its snapshot is refused by disposition. Managed state is engine-ineligible. Even current native dry-run evidence is recorded only as a report-only handoff because this engine cannot independently authenticate the owning product's state -or cleanup contract. +or cleanup contract. The report each registry match produces is specified in +[managed-state-report.md](managed-state-report.md). The baseline policy therefore ships no discovery hint for another product's managed state. A hint for a class the engine will never act on tells the operator to look for residue the plugin has From c68862f9df868348ccf97dd9fe400101ed230408 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:04:36 -0400 Subject: [PATCH 03/15] test(disk-hygiene): prove the managed-state registry grants no approval and leaves the engine unchanged Registry-matched paths (.pulumi, .cache/chezmoi, .codex, and the rest) classify and preview exactly like neutral controls, a claimed registry owner stays report-only with no token, the token depends on snapshot and plan alone, apply stays behind --execute, tier and a fresh token, and the engine never reads the registry or runs a registry tool. The baseline policy loads identically with the registry absent. Co-Authored-By: Claude Opus 5.5 --- .../clean/scripts/test_owner_registry.py | 269 +++++++++++++++++- 1 file changed, 268 insertions(+), 1 deletion(-) diff --git a/plugins/disk-hygiene/skills/clean/scripts/test_owner_registry.py b/plugins/disk-hygiene/skills/clean/scripts/test_owner_registry.py index da249f89da..b3f2b77c0f 100644 --- a/plugins/disk-hygiene/skills/clean/scripts/test_owner_registry.py +++ b/plugins/disk-hygiene/skills/clean/scripts/test_owner_registry.py @@ -3,11 +3,27 @@ from __future__ import annotations +import ast import fnmatch +import importlib.util +import inspect +import io import json +import shutil +import subprocess +import tempfile import unittest -from pathlib import Path +from contextlib import ExitStack, redirect_stdout +from pathlib import Path, PurePosixPath from typing import Any +from unittest import mock + +SCRIPTS = Path(__file__).resolve().parent +ENGINE_PATH = SCRIPTS / "hygiene.py" +_spec = importlib.util.spec_from_file_location("hygiene", ENGINE_PATH) +assert _spec and _spec.loader +hygiene = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(hygiene) REFERENCE = Path(__file__).resolve().parents[1] / "reference" REGISTRY = json.loads((REFERENCE / "owner-registry.json").read_text(encoding="utf-8")) @@ -117,5 +133,256 @@ def test_no_path_pattern_touches_a_protected_name(self) -> None: self.assertFalse(fnmatch.fnmatchcase(segment, glob), entry["id"]) +REGISTRY_TOOLS = {entry["tool"] for entry in REGISTRY["entries"]} +REGISTRY_FRAGMENTS = ( + "owner-registry", + "owner_registry", + "destructive_native_command", + "read_only_command", +) +NATIVE_EVIDENCE = {"command": "fixture-manager prune --dry-run", "result": "eligible"} + + +def registry_matches() -> list[tuple[str, str]]: + """Every registry path pattern with the id of the entry that owns it.""" + return sorted( + { + (pattern, entry["id"]) + for entry in REGISTRY["entries"] + for patterns in entry["path_patterns"].values() + for pattern in patterns + } + ) + + +def plan_for(path: str, owner: str = "unmanaged", tier: str = "high") -> dict[str, Any]: + item: dict[str, Any] = { + "path": path, + "tier": tier, + "provenance": "fixture convention documents this as abandoned staging", + "reason": "fixture provenance identifies an abandoned temporary", + "evidence": ["name matches fixture convention", "owner process is absent"], + "why_not_work_product": "fixture content is generated and has no consumer", + "risk": "low: fixture residue with no live consumer", + "owner": owner, + } + if owner != "unmanaged": + item["native_gc_evidence"] = NATIVE_EVIDENCE + return {"version": 1, "tier": tier, "candidates": [item]} + + +class RegistryGrantsNoApprovalTest(unittest.TestCase): + """A registry match changes nothing about what the engine may delete.""" + + def setUp(self) -> None: + stack = ExitStack() + self.addCleanup(stack.close) + for name, value in ( + ("standing_policy_paths", []), + ("execution_blockers", []), + ("handle_state", ("clear", None)), + ("tracked_blocker", None), + ): + stack.enter_context(mock.patch.object(hygiene, name, return_value=value)) + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.base = Path(temporary.name) + self.home = self.base / "home" + # Each registry path gets a control: same parent, same content shape, + # a name no registry entry mentions. + self.controls: dict[str, str] = {} + for index, (path, _) in enumerate(registry_matches()): + control = str(PurePosixPath(path).parent / f"control-{index}") + self.controls[path] = control + for relative in (path, control): + directory = self.home.joinpath(*PurePosixPath(relative).parts) + directory.mkdir(parents=True) + (directory / "state.dat").write_text("state", encoding="utf-8") + self.snapshot = hygiene.scan_tree( + self.home.resolve(), hygiene.load_policy(None) + ) + + def run_apply(self, plan: dict[str, Any], *flags: str) -> tuple[int, str]: + snapshot_path = self.base / "snapshot.json" + plan_path = self.base / "plan.json" + snapshot_path.write_text(json.dumps(self.snapshot), encoding="utf-8") + plan_path.write_text(json.dumps(plan), encoding="utf-8") + output = io.StringIO() + with ( + mock.patch.object(hygiene, "apply_plan") as apply_plan, + redirect_stdout(output), + ): + code = hygiene.main( + [ + "apply", + "--snapshot", + str(snapshot_path), + "--plan", + str(plan_path), + "--report", + str(self.base / "report.json"), + *flags, + ] + ) + apply_plan.assert_not_called() + return code, output.getvalue() + + def test_registry_paths_are_classified_like_neutral_paths(self) -> None: + entries = hygiene.entry_map(self.snapshot) + for path, control in self.controls.items(): + for suffix in ("", "/state.dat"): + seen, expected = entries[path + suffix], entries[control + suffix] + for field in ("hints", "protected_reasons"): + self.assertEqual(expected[field], seen[field], path + suffix) + + def test_preview_gives_a_registry_path_the_verdict_a_neutral_path_gets( + self, + ) -> None: + for path, control in self.controls.items(): + seen = hygiene.preview(self.snapshot, plan_for(path)) + expected = hygiene.preview(self.snapshot, plan_for(control)) + for field in ("status", "outcome"): + self.assertEqual(expected[field], seen[field], path) + self.assertEqual( + expected["candidates"][0]["blockers"], + seen["candidates"][0]["blockers"], + path, + ) + self.assertEqual( + expected["approval_token"] is None, seen["approval_token"] is None + ) + + def test_a_plan_claiming_the_registry_owner_stays_report_only(self) -> None: + for path, owner in registry_matches(): + result = hygiene.preview(self.snapshot, plan_for(path, owner)) + self.assertIn( + "native-managed-report-only", result["candidates"][0]["blockers"] + ) + self.assertEqual("blocked", result["status"], path) + self.assertIsNone(result["approval_token"], path) + + def test_token_is_a_function_of_snapshot_and_plan_alone(self) -> None: + self.assertEqual( + ["snapshot", "plan"], + list(inspect.signature(hygiene.approval_token).parameters), + ) + path = registry_matches()[0][0] + plan = plan_for(path) + token = hygiene.preview(self.snapshot, plan)["approval_token"] + self.assertEqual(hygiene.approval_token(self.snapshot, plan), token) + self.assertNotEqual( + token, hygiene.approval_token(self.snapshot, plan_for(path, tier="medium")) + ) + + def test_the_plan_list_is_bound_to_the_snapshot_not_the_registry(self) -> None: + path = registry_matches()[0][0] + self.snapshot["entries"] = [ + entry for entry in self.snapshot["entries"] if entry["path"] != path + ] + with self.assertRaises(hygiene.HygieneError): + hygiene.preview(self.snapshot, plan_for(path)) + + def test_apply_refuses_without_execute_a_matching_tier_and_a_fresh_token( + self, + ) -> None: + path = registry_matches()[0][0] + plan = plan_for(path) + fresh = hygiene.preview(self.snapshot, plan)["approval_token"] + for flags, message in ( + (("--confirm-tier", "high", "--approval-token", fresh), "--execute"), + ( + ("--execute", "--confirm-tier", "medium", "--approval-token", fresh), + "confirm-tier", + ), + ( + ("--execute", "--confirm-tier", "high", "--approval-token", "0" * 24), + "approval token", + ), + ): + code, output = self.run_apply(plan, *flags) + self.assertEqual(2, code, flags) + self.assertIn(message, output) + self.assertTrue(self.home.joinpath(*PurePosixPath(path).parts).exists()) + + def test_engine_reads_no_registry_file_and_runs_no_registry_tool(self) -> None: + real_read_text = Path.read_text + reads: list[str] = [] + + def spy(self: Path, *args: Any, **kwargs: Any) -> str: + reads.append(self.name) + return real_read_text(self, *args, **kwargs) + + with ( + mock.patch.object(Path, "read_text", spy), + mock.patch.object(hygiene.subprocess, "run", wraps=subprocess.run) as run, + mock.patch.object( + hygiene.subprocess, "Popen", wraps=subprocess.Popen + ) as popen, + ): + snapshot = hygiene.scan_tree(self.home.resolve(), hygiene.load_policy(None)) + for path, owner in registry_matches(): + hygiene.preview(snapshot, plan_for(path)) + hygiene.preview(snapshot, plan_for(path, owner)) + self.assertIn("baseline-policy.json", reads) + self.assertNotIn("owner-registry.json", reads) + for call in (*run.call_args_list, *popen.call_args_list): + argv = call.args[0] if call.args else call.kwargs.get("args", []) + argv = [argv] if isinstance(argv, str) else list(argv) + self.assertNotIn(Path(str(argv[0])).name if argv else "", REGISTRY_TOOLS) + + +class EngineSourceTest(unittest.TestCase): + tree = ast.parse(ENGINE_PATH.read_text(encoding="utf-8")) + + def test_engine_source_never_names_the_registry_or_its_commands(self) -> None: + words = [ + node.value + for node in ast.walk(self.tree) + if isinstance(node, ast.Constant) and isinstance(node.value, str) + ] + [ + node.id if isinstance(node, ast.Name) else node.attr + for node in ast.walk(self.tree) + if isinstance(node, (ast.Name, ast.Attribute)) + ] + self.assertTrue(words) + for fragment in REGISTRY_FRAGMENTS: + self.assertEqual([], [word for word in words if fragment in word], fragment) + + def test_apply_plan_has_exactly_one_caller_and_it_is_the_cli_apply_lane( + self, + ) -> None: + references = [ + node + for node in ast.walk(self.tree) + if isinstance(node, ast.Name) and node.id == "apply_plan" + ] + callers = [ + function.name + for function in ast.walk(self.tree) + if isinstance(function, ast.FunctionDef) + for node in ast.walk(function) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "apply_plan" + ] + self.assertEqual(1, len(references)) + self.assertEqual(["main"], callers) + + +class BaselinePolicyUnchangedTest(unittest.TestCase): + def test_baseline_policy_loads_identically_without_the_registry(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + bundle = Path(temporary) / "baseline-policy.json" + shutil.copyfile(REFERENCE / "baseline-policy.json", bundle) + with mock.patch.object(hygiene, "standing_policy_paths", return_value=[]): + with_registry = hygiene.load_policy(None) + with mock.patch.object(hygiene, "BASELINE_POLICY", bundle): + without_registry = hygiene.load_policy(None) + self.assertEqual(without_registry, with_registry) + self.assertEqual(["baseline"], with_registry["policy_sources"]) + for key in ("protected_exact_names", "protected_name_globs", "hints"): + self.assertEqual(POLICY[key], with_registry[key], key) + + if __name__ == "__main__": unittest.main() From e4e9033f98d69da296415c8e56b17a7537a6a79c Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:05:59 -0400 Subject: [PATCH 04/15] feat(disk-hygiene): release the read-only managed-state owner registry as 0.30.0 Co-Authored-By: Claude Opus 5.5 --- plugins/disk-hygiene/.claude-plugin/plugin.json | 2 +- plugins/disk-hygiene/CHANGELOG.md | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/plugins/disk-hygiene/.claude-plugin/plugin.json b/plugins/disk-hygiene/.claude-plugin/plugin.json index 066b53f004..f14b9b97a1 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.29.0", + "version": "0.30.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 5e1fa6c271..5ff38a0da9 100644 --- a/plugins/disk-hygiene/CHANGELOG.md +++ b/plugins/disk-hygiene/CHANGELOG.md @@ -3,6 +3,17 @@ 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.30.0] - 2026-09-30 + +### Added + +- **A read-only managed-state owner registry** + ([#4006](https://github.com/melodic-software/claude-code-plugins/issues/4006)). + `skills/clean/reference/owner-registry.json` maps managed-state locations to the tool that owns + them, validated by `owner-registry.schema.json`, with `managed-state-report.md` specifying how a + registry match is reported. A match grants no approval and adds no delete path; the engine is + unchanged. Product-native destructive commands are not included. + ## [0.29.0] - 2026-09-29 ### Added From 5419f28ca4628dd7e7d20f33695f6645e3e895ec Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:22:43 -0400 Subject: [PATCH 05/15] test(disk-hygiene): fence deletion and registry commands to the engine's apply lane The registry tests proved a registry match grants no approval, but nothing failed if a new file ran a registry command or deleted outside the engine, and the engine-only naming check would have rejected the shared route while a parallel one passed. A plugin-wide scan now fails any shipped file outside the engine's apply lane that deletes, names the registry or one of its commands, references apply_plan or anchored_remove, or imports a process runner other than the engine and the telemetry emitter. Planted parallel paths are asserted to fail and the apply lane is asserted to pass. anchored_remove must have apply_plan as its only caller. managed-state-report.md no longer says the destructive command is routed through the engine's approval: the route is not built and the engine blocks an owner-claimed plan as native-managed-report-only. Co-Authored-By: Claude Sonnet 5.5 --- .../clean/reference/managed-state-report.md | 13 +- .../clean/scripts/test_owner_registry.py | 177 +++++++++++++++--- 2 files changed, 160 insertions(+), 30 deletions(-) diff --git a/plugins/disk-hygiene/skills/clean/reference/managed-state-report.md b/plugins/disk-hygiene/skills/clean/reference/managed-state-report.md index 470e3160bf..1025cc448b 100644 --- a/plugins/disk-hygiene/skills/clean/reference/managed-state-report.md +++ b/plugins/disk-hygiene/skills/clean/reference/managed-state-report.md @@ -14,18 +14,19 @@ managed state stay as [the safety model](safety-model.md) states them. 3. **Read-only command.** When present and `read_only_command` is set, run it and capture its output into the report verbatim. A null command means the product has none; the report shows `manual_step` as information. -4. **Destructive native command.** Shown as information only, never run by the report. Running it - is a separate act routed through the engine's existing approval: the tier, the exact-path list - and a fresh preview. This lane has no gate of its own. +4. **Destructive native command.** Shown as information only, never run by the report. No route + runs it: the engine blocks a plan that claims a registry owner with `native-managed-report-only` + and issues no approval token for it. A route would need an engine change, and the owner decides + whether to make one. 5. **Unmatched paths.** A managed-looking path with no registry match is reported as a coverage gap. It is never `clean` and never removable. ## Design check -| #4006 design constraint | How this report meets it | +| Design constraint | How this report meets it | |---|---| | Containment is untouched | The report adds no deletion capability; engine eligibility is unchanged. | -| Read-only and destructive are different gates | Step 3 runs freely; step 4 is information routed to the engine's approval. | +| Read-only and destructive are different gates | Step 3 runs freely; step 4 is information only, and no route to run it is built. | | Tool presence is checked first | Step 2 precedes every command; absent gives `absent-tool` and no commands. | | An entry is a hint, never authorization | Step 1 treats a match as a claim to prove. | | Unmatched stays a coverage gap | Step 5. | @@ -34,4 +35,4 @@ managed state stay as [the safety model](safety-model.md) states them. ## Scope No destructive command is built. Whether a product-native destructive command is ever run stays -the owner's decision (#4006). +the owner's decision. diff --git a/plugins/disk-hygiene/skills/clean/scripts/test_owner_registry.py b/plugins/disk-hygiene/skills/clean/scripts/test_owner_registry.py index b3f2b77c0f..e3769da181 100644 --- a/plugins/disk-hygiene/skills/clean/scripts/test_owner_registry.py +++ b/plugins/disk-hygiene/skills/clean/scripts/test_owner_registry.py @@ -331,42 +331,171 @@ def spy(self: Path, *args: Any, **kwargs: Any) -> str: self.assertNotIn(Path(str(argv[0])).name if argv else "", REGISTRY_TOOLS) -class EngineSourceTest(unittest.TestCase): - tree = ast.parse(ENGINE_PATH.read_text(encoding="utf-8")) +PLUGIN = Path(__file__).resolve().parents[3] +TELEMETRY_PATH = PLUGIN / "lib" / "hook_telemetry.py" +EXECUTORS = {ENGINE_PATH, TELEMETRY_PATH} +# The engine's apply lane: the only code that may delete or name a registry command. +APPLY_LANE = {"apply_plan", "anchored_remove"} +FENCED = ("apply_plan", "anchored_remove") +CODE_SUFFIXES = {".py", ".sh", ".mjs", ".js", ".ps1"} +DELETIONS = {"unlink", "rmdir", "rmtree", "removedirs"} +RUNNERS = ( + "subprocess", + "os.system", + "os.popen", + "os.exec", + "os.spawn", + "os.posix_spawn", + "pty.spawn", + "asyncio.create_subprocess", +) +REGISTRY_COMMANDS = tuple( + part.strip() + for entry in REGISTRY["entries"] + for command in (entry["read_only_command"], entry["destructive_native_command"]) + for part in (command or "").split(";") + if part.strip() +) +SHIPPED = sorted( + path + for path in PLUGIN.rglob("*") + if path.suffix in CODE_SUFFIXES + and "__pycache__" not in path.parts + and not path.name.startswith("test_") + and ".test." not in path.name +) - def test_engine_source_never_names_the_registry_or_its_commands(self) -> None: - words = [ - node.value - for node in ast.walk(self.tree) - if isinstance(node, ast.Constant) and isinstance(node.value, str) - ] + [ - node.id if isinstance(node, ast.Name) else node.attr - for node in ast.walk(self.tree) - if isinstance(node, (ast.Name, ast.Attribute)) - ] - self.assertTrue(words) - for fragment in REGISTRY_FRAGMENTS: - self.assertEqual([], [word for word in words if fragment in word], fragment) - def test_apply_plan_has_exactly_one_caller_and_it_is_the_cli_apply_lane( +def without_apply_lane(tree: ast.Module) -> ast.Module: + keep = [ + node + for node in tree.body + if not (isinstance(node, ast.FunctionDef) and node.name in APPLY_LANE) + ] + return ast.Module(body=keep, type_ignores=[]) + + +def uses(tree: ast.AST) -> set[str]: + """Dotted names a module imports or calls.""" + names: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + names.update(alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom): + names.update(f"{node.module}.{alias.name}" for alias in node.names) + elif isinstance(node, ast.Call): + names.add(ast.unparse(node.func)) + return names + + +def violations(path: Path, source: str) -> list[str]: + """What a shipped file does that only the engine's apply lane may do. + + Deleting, running a registry command, naming the registry, and reaching the + apply lane without the CLI's gates are all found by name. Outside the engine + a process runner is also a violation. + """ + text, found = source, set() + if path.suffix == ".py": + tree = ast.parse(source) + if path == ENGINE_PATH: + tree = without_apply_lane(tree) + text, found = ast.unparse(tree), uses(tree) + hits = { + name + for name in found + if name == "os.remove" or name.rpartition(".")[2] in DELETIONS + } + if path not in EXECUTORS: + hits |= {name for name in found if name.startswith(RUNNERS)} + hits |= {name for name in (*REGISTRY_FRAGMENTS, *REGISTRY_COMMANDS) if name in text} + if path != ENGINE_PATH: + hits |= {name for name in FENCED if name in text} + return sorted(hits) + + +class OnlyTheApplyLaneDestroysTest(unittest.TestCase): + """Any destructive path is the engine's: its tier, exact-list and token gates. + + The scan is static. It reads every shipped file, test files excepted; a + non-Python file is checked by name only. It cannot see `getattr`, `eval`, + `importlib` or a command assembled at run time. Widening what a file may do + is an edit to the constants above, in review. + """ + + def test_no_shipped_file_deletes_names_the_registry_or_reaches_the_apply_lane( self, ) -> None: - references = [ - node + self.assertLessEqual({ENGINE_PATH, TELEMETRY_PATH}, set(SHIPPED)) + for path in SHIPPED: + self.assertEqual( + [], + violations(path, path.read_text(encoding="utf-8")), + str(path.relative_to(PLUGIN)), + ) + + def test_a_parallel_path_is_flagged(self) -> None: + elsewhere = SCRIPTS / "parallel.py" + for source in ( + "import subprocess\nsubprocess.run(entry['destructive_native_command'])\n", + "import subprocess as sp\nsp.run(['pulumi'])\n", + "from os import unlink\nunlink(path)\n", + "import shutil\nshutil.rmtree(path)\n", + "import hygiene\nhygiene.apply_plan(snapshot, plan)\n", + ): + self.assertNotEqual([], violations(elsewhere, source), source) + for source in ( + "jq -r '.entries[].destructive_native_command' owner-registry.json | sh\n", + "docker image prune --force\n", + ): + self.assertNotEqual([], violations(SCRIPTS / "parallel.sh", source), source) + + def test_the_apply_lane_and_the_telemetry_emitter_are_not(self) -> None: + lane = ( + "def apply_plan(snapshot, plan):\n" + " subprocess.run(entry['destructive_native_command'])\n" + " anchored_remove(fd, name)\n" + " os.unlink(name)\n" + ) + self.assertEqual([], violations(ENGINE_PATH, lane)) + self.assertNotEqual( + [], violations(ENGINE_PATH, lane.replace("apply_plan", "preview")) + ) + self.assertEqual( + [], + violations(TELEMETRY_PATH, "import subprocess\nsubprocess.Popen(['x'])\n"), + ) + + +class EngineSourceTest(unittest.TestCase): + tree = ast.parse(ENGINE_PATH.read_text(encoding="utf-8")) + + def references(self, name: str) -> int: + return sum( + isinstance(node, ast.Name) and node.id == name for node in ast.walk(self.tree) - if isinstance(node, ast.Name) and node.id == "apply_plan" - ] - callers = [ + ) + + def callers(self, name: str) -> list[str]: + return [ function.name for function in ast.walk(self.tree) if isinstance(function, ast.FunctionDef) for node in ast.walk(function) if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) - and node.func.id == "apply_plan" + and node.func.id == name ] - self.assertEqual(1, len(references)) - self.assertEqual(["main"], callers) + + def test_apply_plan_has_exactly_one_caller_and_it_is_the_cli_apply_lane( + self, + ) -> None: + self.assertEqual(1, self.references("apply_plan")) + self.assertEqual(["main"], self.callers("apply_plan")) + + def test_the_only_removal_is_reached_from_apply_plan(self) -> None: + self.assertEqual(1, self.references("anchored_remove")) + self.assertEqual(["apply_plan"], self.callers("anchored_remove")) class BaselinePolicyUnchangedTest(unittest.TestCase): From 7c98f39fb1ea3a2256ffcaebe4c6d3c747ff5bc1 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:27:34 -0400 Subject: [PATCH 06/15] test(disk-hygiene): close the argv, placeholder and launcher gaps in the apply-lane scan The scan matched a registry command only as a whole string, so the Pulumi destructive command (stored with a placeholder) and a command built as an argv list passed, and a new shell script could delete without being seen. Commands are matched up to their placeholder, registry tool names as string constants are flagged outside the apply lane, hooks.json is scanned, and non-Python files fail on deletion verbs or child_process beyond the launchers' existing count. Planted shell and Python parallel paths are asserted to fail. Co-Authored-By: Claude Sonnet 5.5 --- .../clean/scripts/test_owner_registry.py | 58 +++++++++++++++---- 1 file changed, 47 insertions(+), 11 deletions(-) diff --git a/plugins/disk-hygiene/skills/clean/scripts/test_owner_registry.py b/plugins/disk-hygiene/skills/clean/scripts/test_owner_registry.py index e3769da181..a276a8631c 100644 --- a/plugins/disk-hygiene/skills/clean/scripts/test_owner_registry.py +++ b/plugins/disk-hygiene/skills/clean/scripts/test_owner_registry.py @@ -9,6 +9,7 @@ import inspect import io import json +import re import shutil import subprocess import tempfile @@ -350,16 +351,26 @@ def spy(self: Path, *args: Any, **kwargs: Any) -> str: "asyncio.create_subprocess", ) REGISTRY_COMMANDS = tuple( - part.strip() + verb for entry in REGISTRY["entries"] for command in (entry["read_only_command"], entry["destructive_native_command"]) for part in (command or "").split(";") - if part.strip() + if (verb := part.partition("<")[0].strip()) ) +HOOKS_JSON = PLUGIN / "hooks" / "hooks.json" +ACTIONS = re.compile( + r"\b(?:rm|rmdir|unlink|rimraf|rmSync|unlinkSync|Remove-Item|child_process)\b" + r"|\s-delete\b" +) +# What the launchers already do: remove a cache temp file, spawn bash. +LAUNCHER_ACTIONS = { + PLUGIN / "hooks" / "run-python-hook.sh": 2, + PLUGIN / "hooks" / "exec-bash.mjs": 1, +} SHIPPED = sorted( path for path in PLUGIN.rglob("*") - if path.suffix in CODE_SUFFIXES + if (path.suffix in CODE_SUFFIXES or path == HOOKS_JSON) and "__pycache__" not in path.parts and not path.name.startswith("test_") and ".test." not in path.name @@ -391,17 +402,27 @@ def uses(tree: ast.AST) -> set[str]: def violations(path: Path, source: str) -> list[str]: """What a shipped file does that only the engine's apply lane may do. - Deleting, running a registry command, naming the registry, and reaching the - apply lane without the CLI's gates are all found by name. Outside the engine - a process runner is also a violation. + Deleting, running a registry command, naming the registry or its tools, and + reaching the apply lane without the CLI's gates are all found by name. + Outside the engine and the telemetry emitter a process runner is also a + violation. A non-Python file is checked for deletion verbs beyond the + launchers' pinned count. """ + hits: set[str] = set() text, found = source, set() if path.suffix == ".py": tree = ast.parse(source) if path == ENGINE_PATH: tree = without_apply_lane(tree) text, found = ast.unparse(tree), uses(tree) - hits = { + hits |= { + node.value + for node in ast.walk(tree) + if isinstance(node, ast.Constant) and node.value in REGISTRY_TOOLS + } + elif len(actions := ACTIONS.findall(source)) > LAUNCHER_ACTIONS.get(path, 0): + hits.update(actions) + hits |= { name for name in found if name == "os.remove" or name.rpartition(".")[2] in DELETIONS @@ -417,10 +438,12 @@ def violations(path: Path, source: str) -> list[str]: class OnlyTheApplyLaneDestroysTest(unittest.TestCase): """Any destructive path is the engine's: its tier, exact-list and token gates. - The scan is static. It reads every shipped file, test files excepted; a - non-Python file is checked by name only. It cannot see `getattr`, `eval`, - `importlib` or a command assembled at run time. Widening what a file may do - is an edit to the constants above, in review. + The scan is static. It reads every shipped file, test files excepted. A + non-Python file is checked for registry names, commands and deletion verbs, + not for every process it starts. It cannot see `getattr`, `eval`, + `importlib` or a command assembled at run time, and a move or overwrite is + not counted as a deletion. Widening what a file may do is an edit to the + constants above, in review. """ def test_no_shipped_file_deletes_names_the_registry_or_reaches_the_apply_lane( @@ -439,6 +462,7 @@ def test_a_parallel_path_is_flagged(self) -> None: for source in ( "import subprocess\nsubprocess.run(entry['destructive_native_command'])\n", "import subprocess as sp\nsp.run(['pulumi'])\n", + "def f():\n run(['docker', 'image', 'prune'])\n", "from os import unlink\nunlink(path)\n", "import shutil\nshutil.rmtree(path)\n", "import hygiene\nhygiene.apply_plan(snapshot, plan)\n", @@ -447,8 +471,17 @@ def test_a_parallel_path_is_flagged(self) -> None: for source in ( "jq -r '.entries[].destructive_native_command' owner-registry.json | sh\n", "docker image prune --force\n", + "pulumi plugin rm v3.1.0\n", + 'rm -rf "$HOME/.pulumi"\n', + "find ~/.pulumi -delete\n", ): self.assertNotEqual([], violations(SCRIPTS / "parallel.sh", source), source) + self.assertNotEqual( + [], + violations( + ENGINE_PATH, "def preview():\n run(['pulumi', 'plugin', 'rm'])\n" + ), + ) def test_the_apply_lane_and_the_telemetry_emitter_are_not(self) -> None: lane = ( @@ -465,6 +498,9 @@ def test_the_apply_lane_and_the_telemetry_emitter_are_not(self) -> None: [], violations(TELEMETRY_PATH, "import subprocess\nsubprocess.Popen(['x'])\n"), ) + launcher = PLUGIN / "hooks" / "run-python-hook.sh" + self.assertEqual([], violations(launcher, "rm -f a\nrm -f b\n")) + self.assertNotEqual([], violations(launcher, "rm -f a\nrm -f b\nrm -f c\n")) class EngineSourceTest(unittest.TestCase): From dee6a222aab38f0d7b3343447ff5d5ab5663ac6f Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:43:47 -0400 Subject: [PATCH 07/15] fix(disk-hygiene): mark the owner-registry test executable The file starts with a shebang, so the lint exec-bit check requires mode 100755, as for its sibling test_hygiene.py. Co-Authored-By: Claude Opus 5.5 --- plugins/disk-hygiene/skills/clean/scripts/test_owner_registry.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 plugins/disk-hygiene/skills/clean/scripts/test_owner_registry.py diff --git a/plugins/disk-hygiene/skills/clean/scripts/test_owner_registry.py b/plugins/disk-hygiene/skills/clean/scripts/test_owner_registry.py old mode 100644 new mode 100755 From fdc395dffdbcb03bc59b1e5722a4733ba4e7aea5 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:46:05 -0400 Subject: [PATCH 08/15] docs(disk-hygiene): the managed-state report does not show destructive commands Step 4 printed the native destructive command for an operator to copy, and the design-check row counted that as meeting "read-only and destructive are different gates". Showing the command offers it outside the tier and exact-list approval, and whether the report may show or offer one is the owner's decision (#4006). The registry keeps the string as inspectable data; the report neither shows nor runs it, and the table row states what is and is not built. Co-Authored-By: Claude Opus 5.5 --- plugins/disk-hygiene/CHANGELOG.md | 3 ++- .../skills/clean/reference/managed-state-report.md | 11 ++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/plugins/disk-hygiene/CHANGELOG.md b/plugins/disk-hygiene/CHANGELOG.md index 3f634bfdfd..8364d291bb 100644 --- a/plugins/disk-hygiene/CHANGELOG.md +++ b/plugins/disk-hygiene/CHANGELOG.md @@ -12,7 +12,8 @@ All notable changes to the `disk-hygiene` plugin are documented here. Format fol `skills/clean/reference/owner-registry.json` maps managed-state locations to the tool that owns them, validated by `owner-registry.schema.json`, with `managed-state-report.md` specifying how a registry match is reported. A match grants no approval and adds no delete path; the engine is - unchanged. Product-native destructive commands are not included. + unchanged. The report neither shows nor runs a product-native destructive command; the registry + keeps each as data. ## [0.30.0] - 2026-09-29 diff --git a/plugins/disk-hygiene/skills/clean/reference/managed-state-report.md b/plugins/disk-hygiene/skills/clean/reference/managed-state-report.md index 1025cc448b..5bde495aef 100644 --- a/plugins/disk-hygiene/skills/clean/reference/managed-state-report.md +++ b/plugins/disk-hygiene/skills/clean/reference/managed-state-report.md @@ -14,10 +14,11 @@ managed state stay as [the safety model](safety-model.md) states them. 3. **Read-only command.** When present and `read_only_command` is set, run it and capture its output into the report verbatim. A null command means the product has none; the report shows `manual_step` as information. -4. **Destructive native command.** Shown as information only, never run by the report. No route - runs it: the engine blocks a plan that claims a registry owner with `native-managed-report-only` - and issues no approval token for it. A route would need an engine change, and the owner decides - whether to make one. +4. **Destructive native command.** Neither shown nor run by the report. The registry keeps each one + as data to inspect. The engine blocks a plan that claims a registry owner with + `native-managed-report-only` and issues no approval token for it. Whether the report may show a + destructive command, or offer one behind the engine's tier and exact-list approval, is the + owner's decision, and no route for either is built. 5. **Unmatched paths.** A managed-looking path with no registry match is reported as a coverage gap. It is never `clean` and never removable. @@ -26,7 +27,7 @@ managed state stay as [the safety model](safety-model.md) states them. | Design constraint | How this report meets it | |---|---| | Containment is untouched | The report adds no deletion capability; engine eligibility is unchanged. | -| Read-only and destructive are different gates | Step 3 runs freely; step 4 is information only, and no route to run it is built. | +| Read-only and destructive are different gates | Step 3 runs freely. Step 4 shows and runs no destructive command, so none is offered outside the engine's approval; offering one behind that approval is not built. | | Tool presence is checked first | Step 2 precedes every command; absent gives `absent-tool` and no commands. | | An entry is a hint, never authorization | Step 1 treats a match as a claim to prove. | | Unmatched stays a coverage gap | Step 5. | From bc164c0a27ce29bda854c93a30aaa6ca6237031c Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:53:13 -0400 Subject: [PATCH 09/15] fix(disk-hygiene): keep the clean skill under the 500-line cap Merging origin/main left SKILL.md at 502 lines, and check-changed-skills fails at the 500-line hard cap. Fold the managed-state report pointer into the existing managed-state paragraph in section 4 instead of adding a standalone paragraph. Co-Authored-By: Claude Opus 5.5 --- plugins/disk-hygiene/skills/clean/SKILL.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/plugins/disk-hygiene/skills/clean/SKILL.md b/plugins/disk-hygiene/skills/clean/SKILL.md index e7ece39ace..2398ea9929 100644 --- a/plugins/disk-hygiene/skills/clean/SKILL.md +++ b/plugins/disk-hygiene/skills/clean/SKILL.md @@ -188,9 +188,6 @@ The guard validates `--data-root` against the plugin data directory it derives i the call outright when it cannot recognize the install layout, so a run reporting that denial is a coverage gap, not a clean result. (Derivation and its fail-closed rationale: `reference/safety-model.md`.) -Managed-state registry matches are reported per -[managed-state-report.md](reference/managed-state-report.md). - For a large root (a home directory, anything whose recursive walk could exceed the engine's entry cap), start with a bounded pass: add `--max-depth 1` to inventory the target's loose files and immediate children, then fan out deeper scans per subtree that the evidence justifies. After that depth-1 pass, re-inventory @@ -370,7 +367,7 @@ mix tiers: For managed state, report the documented native command and its current dry-run result, but do not add the path to an engine plan. Paths in an engine plan are unmanaged, snapshot-relative, exact, -non-overlapping, and never globs. +non-overlapping, and never globs. Report a registry match per `reference/managed-state-report.md`. ## 5. Preview, then ask From 98415b195d8c44d0c9c48db2921d2e96bcf502fb Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:26:44 -0400 Subject: [PATCH 10/15] test(disk-hygiene): guard the two engine deletion lanes, not one Main's handoff_apply (#5541) also reaches anchored_remove, and its purge_directory_contents and write_text_atomic delete too, so the guard's apply-lane set and the one-caller assertions no longer described the engine. Deleting is now allowed only in apply_plan, handoff_apply, anchored_remove, purge_directory_contents and write_text_atomic (its own temporary file). A registry command or tool may appear only in apply_plan: handoff_apply takes no plan, so it has no owner claim, and the test pins that signature. Each lane has main as its only caller, anchored_remove has exactly those two, and only the handoff lane asks it to empty Git metadata. The engine is unchanged. Co-Authored-By: Claude Opus 5.5 --- .../clean/scripts/test_owner_registry.py | 168 ++++++++++++++---- 1 file changed, 132 insertions(+), 36 deletions(-) diff --git a/plugins/disk-hygiene/skills/clean/scripts/test_owner_registry.py b/plugins/disk-hygiene/skills/clean/scripts/test_owner_registry.py index a276a8631c..02f80f1dc9 100755 --- a/plugins/disk-hygiene/skills/clean/scripts/test_owner_registry.py +++ b/plugins/disk-hygiene/skills/clean/scripts/test_owner_registry.py @@ -335,9 +335,26 @@ def spy(self: Path, *args: Any, **kwargs: Any) -> str: PLUGIN = Path(__file__).resolve().parents[3] TELEMETRY_PATH = PLUGIN / "lib" / "hook_telemetry.py" EXECUTORS = {ENGINE_PATH, TELEMETRY_PATH} -# The engine's apply lane: the only code that may delete or name a registry command. -APPLY_LANE = {"apply_plan", "anchored_remove"} -FENCED = ("apply_plan", "anchored_remove") +# The engine functions that may delete. `apply_plan` and `handoff_apply` are the two +# lanes, each reached only from the CLI behind its own gates. `anchored_remove`, and +# `purge_directory_contents` beneath it, are the removers both lanes share. +# `write_text_atomic` removes only the temporary file it wrote itself. +DELETERS = { + "apply_plan", + "handoff_apply", + "anchored_remove", + "purge_directory_contents", + "write_text_atomic", +} +# The only function that may name a registry command or tool: `apply_plan` carries +# the plan's owner claim, which `handoff_apply` has no parameter to receive. +REGISTRY_NAMERS = {"apply_plan"} +FENCED = ( + "apply_plan", + "handoff_apply", + "anchored_remove", + "purge_directory_contents", +) CODE_SUFFIXES = {".py", ".sh", ".mjs", ".js", ".ps1"} DELETIONS = {"unlink", "rmdir", "rmtree", "removedirs"} RUNNERS = ( @@ -377,11 +394,11 @@ def spy(self: Path, *args: Any, **kwargs: Any) -> str: ) -def without_apply_lane(tree: ast.Module) -> ast.Module: +def without(tree: ast.Module, names: set[str]) -> ast.Module: keep = [ node for node in tree.body - if not (isinstance(node, ast.FunctionDef) and node.name in APPLY_LANE) + if not (isinstance(node, ast.FunctionDef) and node.name in names) ] return ast.Module(body=keep, type_ignores=[]) @@ -400,31 +417,33 @@ def uses(tree: ast.AST) -> set[str]: def violations(path: Path, source: str) -> list[str]: - """What a shipped file does that only the engine's apply lane may do. + """What a shipped file does that only the engine's lanes may do. Deleting, running a registry command, naming the registry or its tools, and - reaching the apply lane without the CLI's gates are all found by name. - Outside the engine and the telemetry emitter a process runner is also a - violation. A non-Python file is checked for deletion verbs beyond the - launchers' pinned count. + reaching a lane or a remover without the CLI's gates are all found by name. + In the engine, `DELETERS` may delete and `REGISTRY_NAMERS` may name the + registry. Outside the engine and the telemetry emitter a process runner is + also a violation. A non-Python file is checked for deletion verbs beyond + the launchers' pinned count. """ hits: set[str] = set() - text, found = source, set() + text, found, removals = source, set(), set() if path.suffix == ".py": tree = ast.parse(source) + scanned, deleting = tree, tree if path == ENGINE_PATH: - tree = without_apply_lane(tree) - text, found = ast.unparse(tree), uses(tree) + scanned, deleting = without(tree, REGISTRY_NAMERS), without(tree, DELETERS) + text, found, removals = ast.unparse(scanned), uses(scanned), uses(deleting) hits |= { node.value - for node in ast.walk(tree) + for node in ast.walk(scanned) if isinstance(node, ast.Constant) and node.value in REGISTRY_TOOLS } elif len(actions := ACTIONS.findall(source)) > LAUNCHER_ACTIONS.get(path, 0): hits.update(actions) hits |= { name - for name in found + for name in removals if name == "os.remove" or name.rpartition(".")[2] in DELETIONS } if path not in EXECUTORS: @@ -435,8 +454,15 @@ def violations(path: Path, source: str) -> list[str]: return sorted(hits) -class OnlyTheApplyLaneDestroysTest(unittest.TestCase): - """Any destructive path is the engine's: its tier, exact-list and token gates. +class OnlyTheEngineLanesDestroyTest(unittest.TestCase): + """Only the engine deletes, through two lanes that each keep their own gates. + + `apply` needs `--execute`, `--confirm-tier` and a fresh token, then runs + `apply_plan`. `handoff-apply` needs `--execute` and one exact path, verifies + it in process, then runs `handoff_apply`, which takes no plan and so no owner + claim. Both reach `anchored_remove`. A registry command may appear only in + `apply_plan`, so a destructive route built on the registry would sit behind + the tier and token gates. The scan is static. It reads every shipped file, test files excepted. A non-Python file is checked for registry names, commands and deletion verbs, @@ -446,7 +472,7 @@ class OnlyTheApplyLaneDestroysTest(unittest.TestCase): constants above, in review. """ - def test_no_shipped_file_deletes_names_the_registry_or_reaches_the_apply_lane( + def test_no_shipped_file_deletes_names_the_registry_or_reaches_a_lane( self, ) -> None: self.assertLessEqual({ENGINE_PATH, TELEMETRY_PATH}, set(SHIPPED)) @@ -466,6 +492,9 @@ def test_a_parallel_path_is_flagged(self) -> None: "from os import unlink\nunlink(path)\n", "import shutil\nshutil.rmtree(path)\n", "import hygiene\nhygiene.apply_plan(snapshot, plan)\n", + "import hygiene\nhygiene.handoff_apply(snapshot, 'x', {})\n", + "import hygiene\nhygiene.anchored_remove(fd, 'x', entry, {}, target)\n", + "import hygiene\nhygiene.purge_directory_contents(fd, 0, path, check)\n", ): self.assertNotEqual([], violations(elsewhere, source), source) for source in ( @@ -483,7 +512,7 @@ def test_a_parallel_path_is_flagged(self) -> None: ), ) - def test_the_apply_lane_and_the_telemetry_emitter_are_not(self) -> None: + def test_the_engine_lanes_and_the_telemetry_emitter_are_not(self) -> None: lane = ( "def apply_plan(snapshot, plan):\n" " subprocess.run(entry['destructive_native_command'])\n" @@ -494,6 +523,26 @@ def test_the_apply_lane_and_the_telemetry_emitter_are_not(self) -> None: self.assertNotEqual( [], violations(ENGINE_PATH, lane.replace("apply_plan", "preview")) ) + removers = ( + "def handoff_apply(snapshot, relative, evidence):\n" + " anchored_remove(fd, relative)\n" + "def anchored_remove(fd, name):\n" + " purge_directory_contents(fd)\n" + " os.rmdir(name)\n" + "def purge_directory_contents(fd):\n" + " os.unlink(name)\n" + "def write_text_atomic(path, text):\n" + " temporary.unlink()\n" + ) + self.assertEqual([], violations(ENGINE_PATH, removers)) + for name in ("handoff_apply", "anchored_remove", "purge_directory_contents"): + call = " subprocess.run(entry['destructive_native_command'])\n" + self.assertNotEqual( + [], violations(ENGINE_PATH, f"def {name}(x):\n{call}"), name + ) + self.assertNotEqual( + [], violations(ENGINE_PATH, "def preview(x):\n os.unlink(x)\n") + ) self.assertEqual( [], violations(TELEMETRY_PATH, "import subprocess\nsubprocess.Popen(['x'])\n"), @@ -512,26 +561,73 @@ def references(self, name: str) -> int: for node in ast.walk(self.tree) ) + def calls(self, name: str, within: ast.AST | None = None) -> list[tuple[str, Any]]: + """Each call to `name` as (enclosing function, call node), sorted by function.""" + return sorted( + ( + (function.name, node) + for function in ast.walk(within or self.tree) + if isinstance(function, ast.FunctionDef) + for node in ast.walk(function) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == name + ), + key=lambda call: call[0], + ) + def callers(self, name: str) -> list[str]: - return [ - function.name - for function in ast.walk(self.tree) - if isinstance(function, ast.FunctionDef) - for node in ast.walk(function) - if isinstance(node, ast.Call) - and isinstance(node.func, ast.Name) - and node.func.id == name - ] + return [function for function, _ in self.calls(name)] - def test_apply_plan_has_exactly_one_caller_and_it_is_the_cli_apply_lane( - self, - ) -> None: - self.assertEqual(1, self.references("apply_plan")) - self.assertEqual(["main"], self.callers("apply_plan")) + def function(self, name: str) -> ast.FunctionDef: + (found,) = ( + node + for node in ast.walk(self.tree) + if isinstance(node, ast.FunctionDef) and node.name == name + ) + return found + + def test_each_lane_has_exactly_one_caller_and_it_is_the_cli(self) -> None: + for lane in ("apply_plan", "handoff_apply"): + self.assertEqual(1, self.references(lane), lane) + self.assertEqual(["main"], self.callers(lane), lane) + + def test_the_remover_is_reached_only_from_the_two_lanes(self) -> None: + self.assertEqual(2, self.references("anchored_remove")) + self.assertEqual( + ["apply_plan", "handoff_apply"], self.callers("anchored_remove") + ) + + def test_git_metadata_is_emptied_only_from_the_handoff_lane(self) -> None: + self.assertEqual(2, self.references("purge_directory_contents")) + self.assertEqual( + ["anchored_remove", "purge_directory_contents"], + self.callers("purge_directory_contents"), + ) + for lane, keywords in ( + ("apply_plan", []), + ("handoff_apply", ["purge_protected"]), + ): + (call,) = ( + node for _, node in self.calls("anchored_remove", self.function(lane)) + ) + self.assertEqual(keywords, [keyword.arg for keyword in call.keywords], lane) + + def test_the_handoff_lane_takes_no_plan_and_so_carries_no_owner_claim(self) -> None: + arguments = self.function("handoff_apply").args + self.assertEqual( + ["snapshot", "relative", "vcs_evidence"], + [argument.arg for argument in arguments.args], + ) - def test_the_only_removal_is_reached_from_apply_plan(self) -> None: - self.assertEqual(1, self.references("anchored_remove")) - self.assertEqual(["apply_plan"], self.callers("anchored_remove")) + def test_write_text_atomic_removes_only_its_own_temporary_file(self) -> None: + removals = [ + ast.unparse(node.func) + for node in ast.walk(self.function("write_text_atomic")) + if isinstance(node, ast.Call) + and ast.unparse(node.func).rpartition(".")[2] in DELETIONS | {"remove"} + ] + self.assertEqual(["temporary.unlink"], removals) class BaselinePolicyUnchangedTest(unittest.TestCase): From 797bb1035f7a4ba907ac79e1a660c4565d558581 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:49:49 -0400 Subject: [PATCH 11/15] docs(disk-hygiene): give a registry match precedence over the native-command handoff Section 4 now says a registry match follows managed-state-report.md alone, whose step 4 shows no destructive command, and that the native-command handoff applies to managed state with no registry match. Step 5 of the report points unmatched paths at that handoff. The two documents no longer give opposite instructions for a registry match. Refs #4006 Co-Authored-By: Claude Opus 5.5 --- plugins/disk-hygiene/skills/clean/SKILL.md | 6 +++--- .../skills/clean/reference/managed-state-report.md | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/plugins/disk-hygiene/skills/clean/SKILL.md b/plugins/disk-hygiene/skills/clean/SKILL.md index 4bb169bc86..4a258fe17d 100644 --- a/plugins/disk-hygiene/skills/clean/SKILL.md +++ b/plugins/disk-hygiene/skills/clean/SKILL.md @@ -366,9 +366,9 @@ mix tiers: } ``` -For managed state, report the documented native command and its current dry-run result, but do not add -the path to an engine plan. Paths in an engine plan are unmanaged, snapshot-relative, exact, -non-overlapping, and never globs. Report a registry match per `reference/managed-state-report.md`. +Managed state never enters an engine plan, whose paths are unmanaged, snapshot-relative, exact, non-overlapping, +and never globs. A registry match follows only `reference/managed-state-report.md`; its step 4 shows no destructive +command. Other managed state: report the documented native command and its current dry-run result. ## 5. Preview, then ask diff --git a/plugins/disk-hygiene/skills/clean/reference/managed-state-report.md b/plugins/disk-hygiene/skills/clean/reference/managed-state-report.md index 5bde495aef..ee5d8980bd 100644 --- a/plugins/disk-hygiene/skills/clean/reference/managed-state-report.md +++ b/plugins/disk-hygiene/skills/clean/reference/managed-state-report.md @@ -20,7 +20,8 @@ managed state stay as [the safety model](safety-model.md) states them. destructive command, or offer one behind the engine's tier and exact-list approval, is the owner's decision, and no route for either is built. 5. **Unmatched paths.** A managed-looking path with no registry match is reported as a coverage - gap. It is never `clean` and never removable. + gap. It is never `clean` and never removable. It gets the native-command handoff that SKILL.md §4 + gives managed state with no registry match. ## Design check From ee9661d4f8c7e93e1fb3c659bdeb836c03c31faf Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:53:10 -0400 Subject: [PATCH 12/15] docs(disk-hygiene): scope the managed-state handoff text to the no-match case The skill's section 1 bullet and the README now say a registry match follows managed-state-report.md, so the native-command handoff text no longer reads as applying to a registry match. The report doc's step 5 returns to the owner-approved wording. Refs #4006 Co-Authored-By: Claude Opus 5.5 --- plugins/disk-hygiene/README.md | 5 +++-- plugins/disk-hygiene/skills/clean/SKILL.md | 2 +- .../skills/clean/reference/managed-state-report.md | 3 +-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/plugins/disk-hygiene/README.md b/plugins/disk-hygiene/README.md index c30bc9fc7e..edc658bf90 100644 --- a/plugins/disk-hygiene/README.md +++ b/plugins/disk-hygiene/README.md @@ -39,8 +39,9 @@ contract); it never follows links or recursively deletes an unvalidated tree. - A live-handle preflight runs immediately before deletion. Windows uses an exclusive `CreateFile` probe for every entry. Linux/macOS require `lsof`; absence, incomplete authority, or diagnostics produce `handle_state_unverified` and block the tier. The plugin never elevates itself. -- Managed state is always a report-only handoff to the owning product's documented cleanup/GC command. - A dry-run result is evidence for the report, never authorization for this engine to remove it. +- Managed state with no registry match is always a report-only handoff to the owning product's + documented cleanup/GC command. A dry-run result is evidence for the report, never authorization for + this engine to remove it. A registry match follows `skills/clean/reference/managed-state-report.md`. - The skill-scoped guard is a fail-closed allowlist. It permits only canonical bundled scan/preview calls made from literal shell words, returns `ask` for the two exact mutating shapes, `apply` and `handoff-apply`, and denies every other Bash command. Brace, tilde, parameter, command, arithmetic, process, word-splitting, diff --git a/plugins/disk-hygiene/skills/clean/SKILL.md b/plugins/disk-hygiene/skills/clean/SKILL.md index 4a258fe17d..fcd18d8669 100644 --- a/plugins/disk-hygiene/skills/clean/SKILL.md +++ b/plugins/disk-hygiene/skills/clean/SKILL.md @@ -93,7 +93,7 @@ blocked target, 3 when elevation is needed or filesystem state could not be veri operator plainly that unpushed commits and untracked or ignored files in that checkout will be lost. - For state owned by a package manager, plugin manager, browser, IDE, cloud-sync client, or similar product, research its documented dry-run/prune/GC command and report the handoff. Managed state is - never eligible for this engine, even when a native dry-run calls it eligible. + never eligible for this engine, even when a native dry-run calls it eligible. A registry match: §4. - Never install a dependency, close another process's handle, or disable a retention mechanism. - While the scan output's `elevation` is `never` (the default), never elevate or trigger UAC/sudo. Report `needs-elevation` or `handle-state-unverified` and stop that tier. With `uac-prompt`, on diff --git a/plugins/disk-hygiene/skills/clean/reference/managed-state-report.md b/plugins/disk-hygiene/skills/clean/reference/managed-state-report.md index ee5d8980bd..5bde495aef 100644 --- a/plugins/disk-hygiene/skills/clean/reference/managed-state-report.md +++ b/plugins/disk-hygiene/skills/clean/reference/managed-state-report.md @@ -20,8 +20,7 @@ managed state stay as [the safety model](safety-model.md) states them. destructive command, or offer one behind the engine's tier and exact-list approval, is the owner's decision, and no route for either is built. 5. **Unmatched paths.** A managed-looking path with no registry match is reported as a coverage - gap. It is never `clean` and never removable. It gets the native-command handoff that SKILL.md §4 - gives managed state with no registry match. + gap. It is never `clean` and never removable. ## Design check From 0355a899034085d5a733fa9bdbca26421402443a Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:19:13 -0400 Subject: [PATCH 13/15] docs(disk-hygiene): name the lane that runs the managed-state report's probes The report told the agent to resolve the tool on PATH and run the registry's read-only command, and its design check said step 3 runs freely. The clean skill's Bash guard denies both on every platform, and the registry test fences read-only commands as it fences destructive ones, so no shipped code runs them either. The report now says the agent runs them in the PowerShell lane, where the guard gives no decision, or the operator runs them and the report records the output, and the design-check rows and the test docstring say the same. Refs #4006 Co-Authored-By: Claude Sonnet 5.5 --- plugins/disk-hygiene/CHANGELOG.md | 3 ++- .../clean/reference/managed-state-report.md | 27 ++++++++++++++----- .../clean/scripts/test_owner_registry.py | 4 ++- 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/plugins/disk-hygiene/CHANGELOG.md b/plugins/disk-hygiene/CHANGELOG.md index 61cfbdeda9..725eca46d3 100644 --- a/plugins/disk-hygiene/CHANGELOG.md +++ b/plugins/disk-hygiene/CHANGELOG.md @@ -13,7 +13,8 @@ All notable changes to the `disk-hygiene` plugin are documented here. Format fol them, validated by `owner-registry.schema.json`, with `managed-state-report.md` specifying how a registry match is reported. A match grants no approval and adds no delete path; the engine is unchanged. The report neither shows nor runs a product-native destructive command; the registry - keeps each as data. + keeps each as data. Its presence check and read-only command run through the PowerShell tool or + the operator, because the skill's Bash guard denies them. ## [0.35.2] - 2026-09-30 diff --git a/plugins/disk-hygiene/skills/clean/reference/managed-state-report.md b/plugins/disk-hygiene/skills/clean/reference/managed-state-report.md index 5bde495aef..3c74f6328a 100644 --- a/plugins/disk-hygiene/skills/clean/reference/managed-state-report.md +++ b/plugins/disk-hygiene/skills/clean/reference/managed-state-report.md @@ -9,11 +9,13 @@ managed state stay as [the safety model](safety-model.md) states them. 1. **Owner.** The entry's `owner` and `id`, with the matched path. The match is a hint for an owner claim, not proof of one. -2. **Tool presence.** Resolve the entry's `tool` on PATH before anything else. Absent: status - `absent-tool`, and the report offers no command of any kind, including the manual step. -3. **Read-only command.** When present and `read_only_command` is set, run it and capture its - output into the report verbatim. A null command means the product has none; the report shows - `manual_step` as information. +2. **Tool presence.** Before any other command, resolve the entry's `tool` on PATH in the lane + named under [Who runs the probes](#who-runs-the-probes). Absent: status `absent-tool`, and the + report offers no command of any kind, including the manual step. Not run: the report says + presence is unverified and runs nothing further. +3. **Read-only command.** When present and `read_only_command` is set, run it in that lane and + capture its output into the report verbatim. A null command means the product has none; the + report shows `manual_step` as information. 4. **Destructive native command.** Neither shown nor run by the report. The registry keeps each one as data to inspect. The engine blocks a plan that claims a registry owner with `native-managed-report-only` and issues no approval token for it. Whether the report may show a @@ -22,13 +24,24 @@ managed state stay as [the safety model](safety-model.md) states them. 5. **Unmatched paths.** A managed-looking path with no registry match is reported as a coverage gap. It is never `clean` and never removable. +## Who runs the probes + +Steps 2 and 3 are tool calls or operator actions, never shipped code. The clean skill's Bash guard +denies both, since neither is a bundled engine shape or a listed supporting command (see the Bash +and PowerShell lane bullets under [Gotchas](../SKILL.md#gotchas)). Where the session has the +PowerShell tool, run them there (`Get-Command `, then the command): that lane is open for +read-only support work and the guard gives those commands no decision, so the session's ordinary +permissions, and in auto mode its classifier, still decide. Otherwise the operator runs both +outside the session, presence check first, and the report records what they paste. A probe nobody +ran is reported as not run, never as a result. + ## Design check | Design constraint | How this report meets it | |---|---| | Containment is untouched | The report adds no deletion capability; engine eligibility is unchanged. | -| Read-only and destructive are different gates | Step 3 runs freely. Step 4 shows and runs no destructive command, so none is offered outside the engine's approval; offering one behind that approval is not built. | -| Tool presence is checked first | Step 2 precedes every command; absent gives `absent-tool` and no commands. | +| Read-only and destructive are different gates | Step 3 is a tool call in the PowerShell lane or an operator action, never the engine and never shipped code. Step 4 shows and runs no destructive command, so none is offered outside the engine's approval; offering one behind that approval is not built. The test fence names both kinds of command, so a shipped probe runner would be a reviewed change to that fence. | +| Tool presence is checked first | Step 2 runs before step 3, in the same lane; absent gives `absent-tool` and no commands. | | An entry is a hint, never authorization | Step 1 treats a match as a claim to prove. | | Unmatched stays a coverage gap | Step 5. | | The registry is inspectable | Plain JSON plus a schema, readable without running anything. | diff --git a/plugins/disk-hygiene/skills/clean/scripts/test_owner_registry.py b/plugins/disk-hygiene/skills/clean/scripts/test_owner_registry.py index 02f80f1dc9..df6b63f9e0 100755 --- a/plugins/disk-hygiene/skills/clean/scripts/test_owner_registry.py +++ b/plugins/disk-hygiene/skills/clean/scripts/test_owner_registry.py @@ -462,7 +462,9 @@ class OnlyTheEngineLanesDestroyTest(unittest.TestCase): it in process, then runs `handoff_apply`, which takes no plan and so no owner claim. Both reach `anchored_remove`. A registry command may appear only in `apply_plan`, so a destructive route built on the registry would sit behind - the tier and token gates. + the tier and token gates. Read-only commands are fenced the same way: the + report's probes are run by a session tool or the operator, never by shipped + code. The scan is static. It reads every shipped file, test files excepted. A non-Python file is checked for registry names, commands and deletion verbs, From 79dbe34fc93ef3815f5552b4760f6551ddf9d37a Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:52:15 -0400 Subject: [PATCH 14/15] fix(disk-hygiene): answer review on the managed-state report and registry Consult the registry from the ownership step, keep a manual step when its tool is absent, run the read-only probe by the resolved application executable, and give each registry entry a verification record (claim, basis, as-of date, recheck trigger) that the schema requires. Refs #4006 Co-Authored-By: Claude Opus 5.5 --- plugins/disk-hygiene/CHANGELOG.md | 4 +- plugins/disk-hygiene/skills/clean/SKILL.md | 2 +- .../clean/reference/managed-state-report.md | 25 +++++--- .../clean/reference/owner-registry.json | 63 +++++++++++++++++-- .../reference/owner-registry.schema.json | 17 ++++- .../clean/scripts/test_owner_registry.py | 12 ++++ 6 files changed, 104 insertions(+), 19 deletions(-) diff --git a/plugins/disk-hygiene/CHANGELOG.md b/plugins/disk-hygiene/CHANGELOG.md index 6c42687a7a..499559525e 100644 --- a/plugins/disk-hygiene/CHANGELOG.md +++ b/plugins/disk-hygiene/CHANGELOG.md @@ -14,7 +14,9 @@ All notable changes to the `disk-hygiene` plugin are documented here. Format fol registry match is reported. A match grants no approval and adds no delete path; the engine is unchanged. The report neither shows nor runs a product-native destructive command; the registry keeps each as data. Its presence check and read-only command run through the PowerShell tool or - the operator, because the skill's Bash guard denies them. + the operator, because the skill's Bash guard denies them, and by the resolved application + executable so a profile alias cannot stand in. An absent tool suppresses the commands, not the + manual step. Each entry carries a verification record: claim, basis, as-of date, recheck trigger. ## [0.37.0] - 2026-09-30 diff --git a/plugins/disk-hygiene/skills/clean/SKILL.md b/plugins/disk-hygiene/skills/clean/SKILL.md index fcd18d8669..48c9b87eb9 100644 --- a/plugins/disk-hygiene/skills/clean/SKILL.md +++ b/plugins/disk-hygiene/skills/clean/SKILL.md @@ -269,7 +269,7 @@ For each hinted or suspicious entry, inspect enough neighboring content and meta 1. What created it? Prefer a manifest, log, documented naming contract, sibling structure, or owning tool over an age/name guess. 2. Is the owner active? Check current process/tool state without killing, pausing, or modifying it. -3. Does the owning system provide cleanup or retention? Its dry-run result is authoritative. +3. Does the owning system provide cleanup or retention? Match `reference/owner-registry.json` `path_patterns` first (§4); its dry-run result is authoritative. 4. Could this be real work product, a resumable download, a backup, a dependency pinned by constraints, or a shell/cloud-sync folder? If uncertain, keep it. 5. Is the evidence current for this exact path? Re-resolve every sibling independently; never diff --git a/plugins/disk-hygiene/skills/clean/reference/managed-state-report.md b/plugins/disk-hygiene/skills/clean/reference/managed-state-report.md index 3c74f6328a..603b4ecf6c 100644 --- a/plugins/disk-hygiene/skills/clean/reference/managed-state-report.md +++ b/plugins/disk-hygiene/skills/clean/reference/managed-state-report.md @@ -9,13 +9,15 @@ managed state stay as [the safety model](safety-model.md) states them. 1. **Owner.** The entry's `owner` and `id`, with the matched path. The match is a hint for an owner claim, not proof of one. -2. **Tool presence.** Before any other command, resolve the entry's `tool` on PATH in the lane - named under [Who runs the probes](#who-runs-the-probes). Absent: status `absent-tool`, and the - report offers no command of any kind, including the manual step. Not run: the report says - presence is unverified and runs nothing further. -3. **Read-only command.** When present and `read_only_command` is set, run it in that lane and - capture its output into the report verbatim. A null command means the product has none; the - report shows `manual_step` as information. +2. **Tool presence.** Before any command, resolve the entry's `tool` to an application executable + in the lane named under [Who runs the probes](#who-runs-the-probes). Absent: status + `absent-tool`, and the report offers no command. `manual_step` is an action in the product's + own interface, not a command, so the report still shows it as information, noting that it + applies only if the product is installed. Not run: the report says presence is unverified and + runs nothing further. +3. **Read-only command.** When present and `read_only_command` is set, run it in that lane by the + resolved executable and capture its output into the report verbatim. A null command means the + product has none; the report shows `manual_step` as information. 4. **Destructive native command.** Neither shown nor run by the report. The registry keeps each one as data to inspect. The engine blocks a plan that claims a registry owner with `native-managed-report-only` and issues no approval token for it. Whether the report may show a @@ -29,9 +31,12 @@ managed state stay as [the safety model](safety-model.md) states them. Steps 2 and 3 are tool calls or operator actions, never shipped code. The clean skill's Bash guard denies both, since neither is a bundled engine shape or a listed supporting command (see the Bash and PowerShell lane bullets under [Gotchas](../SKILL.md#gotchas)). Where the session has the -PowerShell tool, run them there (`Get-Command `, then the command): that lane is open for +PowerShell tool, run them there (`Get-Command -CommandType Application`, then the command): that lane is open for read-only support work and the guard gives those commands no decision, so the session's ordinary -permissions, and in auto mode its classifier, still decide. Otherwise the operator runs both +permissions, and in auto mode its classifier, still decide. A profile alias or function can shadow +a tool's name, so resolve with `Get-Command -CommandType Application`, run the command as +`& '' ` with that resolved path, and treat a name that resolves only to an alias +or function as `absent-tool`. Otherwise the operator runs both outside the session, presence check first, and the report records what they paste. A probe nobody ran is reported as not run, never as a result. @@ -41,7 +46,7 @@ ran is reported as not run, never as a result. |---|---| | Containment is untouched | The report adds no deletion capability; engine eligibility is unchanged. | | Read-only and destructive are different gates | Step 3 is a tool call in the PowerShell lane or an operator action, never the engine and never shipped code. Step 4 shows and runs no destructive command, so none is offered outside the engine's approval; offering one behind that approval is not built. The test fence names both kinds of command, so a shipped probe runner would be a reviewed change to that fence. | -| Tool presence is checked first | Step 2 runs before step 3, in the same lane; absent gives `absent-tool` and no commands. | +| Tool presence is checked first | Step 2 runs before step 3, in the same lane; absent gives `absent-tool` and no commands; the manual step, which is not a command, stays as information. | | An entry is a hint, never authorization | Step 1 treats a match as a claim to prove. | | Unmatched stays a coverage gap | Step 5. | | The registry is inspectable | Plain JSON plus a schema, readable without running anything. | diff --git a/plugins/disk-hygiene/skills/clean/reference/owner-registry.json b/plugins/disk-hygiene/skills/clean/reference/owner-registry.json index 2c74b74dc9..584a1d3b50 100644 --- a/plugins/disk-hygiene/skills/clean/reference/owner-registry.json +++ b/plugins/disk-hygiene/skills/clean/reference/owner-registry.json @@ -13,7 +13,18 @@ "read_only_command": "docker system df", "destructive_native_command": "docker image prune; docker builder prune", "manual_step": null, - "platforms": ["windows", "macos"] + "platforms": ["windows", "macos"], + "verification": { + "claim": "docker system df reports reclaimable space; docker image prune and docker builder prune are the owner's removal commands; the two data paths were seen on a Windows and a macOS host", + "basis": [ + "https://docs.docker.com/reference/cli/docker/system/df/", + "https://docs.docker.com/reference/cli/docker/image/prune/", + "https://docs.docker.com/reference/cli/docker/builder/prune/", + "operator probe of the data paths recorded in issue 4006, no official page found" + ], + "as_of": "2026-09-30", + "recheck": "a Docker CLI release note that renames or removes one of the three commands, or a Docker Desktop release note that moves its data directory" + } }, { "id": "nvidia-shader-cache", @@ -25,7 +36,13 @@ "read_only_command": null, "destructive_native_command": null, "manual_step": "Manual step in Windows Disk Cleanup: select DirectX Shader Cache", - "platforms": ["windows"] + "platforms": ["windows"], + "verification": { + "claim": "the NVIDIA shader cache sits under the listed Windows path and Windows Disk Cleanup offers a DirectX Shader Cache item", + "basis": ["operator probe recorded in issue 4006, no official page fetched"], + "as_of": "2026-09-30", + "recheck": "an NVIDIA driver release note that moves the shader cache, or a Windows build whose Disk Cleanup drops the DirectX Shader Cache item" + } }, { "id": "cursor", @@ -39,7 +56,13 @@ "read_only_command": null, "destructive_native_command": null, "manual_step": "In-app cache clearing in Cursor", - "platforms": ["windows", "macos", "linux"] + "platforms": ["windows", "macos", "linux"], + "verification": { + "claim": "Cursor keeps its state under the listed per-platform user-data paths and offers in-app cache clearing", + "basis": ["operator probe recorded in issue 4006, no official page fetched"], + "as_of": "2026-09-30", + "recheck": "a Cursor changelog entry that moves the user-data directory or removes in-app cache clearing" + } }, { "id": "openai-codex-cli", @@ -53,7 +76,16 @@ "read_only_command": null, "destructive_native_command": null, "manual_step": "Adjust Codex history retention in its configuration", - "platforms": ["windows", "macos", "linux"] + "platforms": ["windows", "macos", "linux"], + "verification": { + "claim": "Codex keeps user configuration under ~/.codex and config.toml offers history.max_bytes and history.persistence", + "basis": [ + "https://learn.chatgpt.com/docs/config-file/config-basic", + "https://learn.chatgpt.com/docs/config-file/config-reference" + ], + "as_of": "2026-09-30", + "recheck": "a Codex release note that changes the config directory or the history.* keys" + } }, { "id": "chezmoi", @@ -67,7 +99,16 @@ "read_only_command": "chezmoi doctor", "destructive_native_command": null, "manual_step": null, - "platforms": ["windows", "macos", "linux"] + "platforms": ["windows", "macos", "linux"], + "verification": { + "claim": "chezmoi's default cacheDir is ~/.cache/chezmoi on every listed platform and chezmoi doctor checks for problems", + "basis": [ + "https://www.chezmoi.io/reference/configuration-file/variables/", + "https://www.chezmoi.io/reference/commands/doctor/" + ], + "as_of": "2026-09-30", + "recheck": "a chezmoi release note that changes the default cacheDir or the doctor command" + } }, { "id": "pulumi", @@ -81,7 +122,17 @@ "read_only_command": "pulumi about; pulumi plugin ls", "destructive_native_command": "pulumi plugin rm ", "manual_step": null, - "platforms": ["windows", "macos", "linux"] + "platforms": ["windows", "macos", "linux"], + "verification": { + "claim": "the plugin cache lives under ~/.pulumi by default, pulumi about prints environment information, pulumi plugin ls lists the cache, and pulumi plugin rm (alias of remove) deletes cached plugins", + "basis": [ + "https://www.pulumi.com/docs/iac/cli/commands/pulumi_plugin_ls/", + "https://www.pulumi.com/docs/iac/cli/commands/pulumi_plugin_rm/", + "https://www.pulumi.com/docs/iac/cli/commands/pulumi_about/" + ], + "as_of": "2026-09-30", + "recheck": "a Pulumi CLI release note that changes the plugin cache location or the plugin ls, plugin rm or about commands" + } } ] } diff --git a/plugins/disk-hygiene/skills/clean/reference/owner-registry.schema.json b/plugins/disk-hygiene/skills/clean/reference/owner-registry.schema.json index 9932b60389..1dc036d38f 100644 --- a/plugins/disk-hygiene/skills/clean/reference/owner-registry.schema.json +++ b/plugins/disk-hygiene/skills/clean/reference/owner-registry.schema.json @@ -13,7 +13,7 @@ "type": "object", "required": [ "id", "owner", "path_patterns", "tool", "read_only_command", - "destructive_native_command", "manual_step", "platforms" + "destructive_native_command", "manual_step", "platforms", "verification" ], "additionalProperties": false, "properties": { @@ -38,6 +38,21 @@ "minItems": 1, "uniqueItems": true, "items": {"enum": ["windows", "macos", "linux"]} + }, + "verification": { + "type": "object", + "required": ["claim", "basis", "as_of", "recheck"], + "additionalProperties": false, + "properties": { + "claim": {"type": "string", "minLength": 1}, + "basis": { + "type": "array", + "minItems": 1, + "items": {"type": "string", "minLength": 1} + }, + "as_of": {"type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}$"}, + "recheck": {"type": "string", "minLength": 1} + } } } } diff --git a/plugins/disk-hygiene/skills/clean/scripts/test_owner_registry.py b/plugins/disk-hygiene/skills/clean/scripts/test_owner_registry.py index df6b63f9e0..6c3683d430 100755 --- a/plugins/disk-hygiene/skills/clean/scripts/test_owner_registry.py +++ b/plugins/disk-hygiene/skills/clean/scripts/test_owner_registry.py @@ -62,6 +62,8 @@ def validate(value: Any, schema: dict[str, Any], where: str = "$") -> list[str]: return [*errors, f"{where}: expected type {names}"] if isinstance(value, str) and len(value) < schema.get("minLength", 0): errors.append(f"{where}: shorter than {schema['minLength']}") + if isinstance(value, str) and not re.search(schema.get("pattern", ""), value): + errors.append(f"{where}: does not match {schema['pattern']}") if isinstance(value, list): if len(value) < schema.get("minItems", 0): errors.append(f"{where}: fewer than {schema['minItems']} items") @@ -110,6 +112,16 @@ def test_validator_rejects_a_malformed_entry(self) -> None: bad["entries"][1]["platforms"] = ["beos"] self.assertEqual(len(validate(bad, SCHEMA)), 2) + def test_every_entry_carries_a_complete_verification_record(self) -> None: + for index, entry in enumerate(REGISTRY["entries"]): + bad = json.loads(json.dumps(REGISTRY)) + del bad["entries"][index]["verification"]["recheck"] + self.assertEqual(len(validate(bad, SCHEMA)), 1, entry["id"]) + bad = json.loads(json.dumps(REGISTRY)) + bad["entries"][index]["verification"]["as_of"] = "last week" + bad["entries"][index]["verification"]["basis"] = [] + self.assertEqual(len(validate(bad, SCHEMA)), 2, entry["id"]) + def test_seeds_the_six_owners(self) -> None: ids = [entry["id"] for entry in REGISTRY["entries"]] self.assertEqual(len(ids), len(set(ids))) From b8a32549c6ff7f6ac807c00f91dc1e20f278496e Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:20:24 -0400 Subject: [PATCH 15/15] fix(disk-hygiene): resolve the executable for each invocation in a compound probe Refs #4006 Co-Authored-By: Claude Opus 5.5 --- .../skills/clean/reference/managed-state-report.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/disk-hygiene/skills/clean/reference/managed-state-report.md b/plugins/disk-hygiene/skills/clean/reference/managed-state-report.md index 603b4ecf6c..245858d982 100644 --- a/plugins/disk-hygiene/skills/clean/reference/managed-state-report.md +++ b/plugins/disk-hygiene/skills/clean/reference/managed-state-report.md @@ -35,7 +35,8 @@ PowerShell tool, run them there (`Get-Command -CommandType Application`, read-only support work and the guard gives those commands no decision, so the session's ordinary permissions, and in auto mode its classifier, still decide. A profile alias or function can shadow a tool's name, so resolve with `Get-Command -CommandType Application`, run the command as -`& '' ` with that resolved path, and treat a name that resolves only to an alias +`& '' ` with that resolved path, once for each `;`-separated invocation in a +compound command (`pulumi about; pulumi plugin ls` is two), and treat a name that resolves only to an alias or function as `absent-tool`. Otherwise the operator runs both outside the session, presence check first, and the report records what they paste. A probe nobody ran is reported as not run, never as a result.