From da568c7cc83a80f30e8ee010addd1e37d2899ac1 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:34:07 -0400 Subject: [PATCH 1/8] feat(disk-hygiene): add handoff-apply, a Linux verify-then-delete route for an acknowledged checkout On Linux, an operator-acknowledged throwaway checkout had no in-engine deletion route: accept_unpublished exists only in handoff-verify, and preview and apply keep VCS protection categorical. handoff-apply takes --execute --snapshot --path --vcs-evidence --report --data-root. It refuses off Linux through execution_blockers(), then runs validate_handoff_paths, validate_vcs_evidence and handoff_verify in-process for exactly that one path, and deletes only on a clear verdict. No verdict is read from a file. preview() and apply_plan are unchanged, and the acknowledgement is evaluated only inside handoff_verify; the report copies the verdict's accept_unpublished entries so the ack shows in the result. After the verdict, every non-VCS check apply_plan runs is repeated per entry: target fd identity, fresh mounts, hard_protection with baseline and snapshot names, consumer globs, same_removal_identity, is_linkish, handle state, and the O_NOFOLLOW fd-relative removal. Only vcs-tracked-content is satisfied by the verdict, and only when it verified the repository evidence. Two additions beyond apply_plan's loop were required, because its anchored removal cannot delete a git checkout: the snapshot records .git without descendants, so the removal refuses it as not empty, and tracked_blocker cannot answer for a path inside .git. The route relaxes the .git marker protections through evidence_adjusted_protections, as handoff-verify does, and empties each verified repository's .git fd-relative (links unlinked, never followed; a mount point or consumer glob match inside it blocks the purge) before the existing anchored rmdir. The route has no plan, so no owner: the native-managed-report-only check has nothing to read here. handoff-apply is a new grammar subcommand rather than a second form of apply, so the apply token shape and its guard admission are unchanged. The guard denies the new name until it is wired. Refs #5178 Co-Authored-By: Claude Sonnet 5.5 --- plugins/disk-hygiene/lib/engine_grammar.py | 25 +- .../skills/clean/scripts/hygiene.py | 306 +++++++++++++++ .../skills/clean/scripts/test_hygiene.py | 354 ++++++++++++++++++ 3 files changed, 684 insertions(+), 1 deletion(-) diff --git a/plugins/disk-hygiene/lib/engine_grammar.py b/plugins/disk-hygiene/lib/engine_grammar.py index bacb2943c6..aef2b47ea3 100644 --- a/plugins/disk-hygiene/lib/engine_grammar.py +++ b/plugins/disk-hygiene/lib/engine_grammar.py @@ -269,10 +269,33 @@ def _data_root_flag() -> Flag: _data_root_flag(), ), ), + Subcommand( + "handoff-apply", + ( + Flag("--execute", takes_value=False, required=True), + Flag("--snapshot", required=True, example="snapshot.json"), + # One exact approved path per call: the engine verifies that path + # against live state and deletes it in the same process. + Flag( + "--path", + required=True, + metavar="RELATIVE", + example="relative/exact.tmp", + help="the one snapshot-relative approved path to verify and delete", + ), + Flag("--vcs-evidence", required=True, example="vcs-evidence.json"), + Flag("--report", required=True, example="report.json"), + _data_root_flag(), + ), + help=( + "verify one approved path as handoff-verify does, then delete it " + "only on a clear verdict (Linux only)" + ), + ), ) # Ordered so a disclosure can name the read-only subcommands first and the -# mutating one last. +# mutating ones last. SUBCOMMAND_NAMES: tuple[str, ...] = tuple(spec.name for spec in SUBCOMMANDS) _SUBCOMMANDS_BY_NAME = {spec.name: spec for spec in SUBCOMMANDS} diff --git a/plugins/disk-hygiene/skills/clean/scripts/hygiene.py b/plugins/disk-hygiene/skills/clean/scripts/hygiene.py index cb8986ef0f..7251d33de8 100755 --- a/plugins/disk-hygiene/skills/clean/scripts/hygiene.py +++ b/plugins/disk-hygiene/skills/clean/scripts/hygiene.py @@ -3980,12 +3980,63 @@ def open_anchored_parent( raise +MAX_PURGE_DEPTH = 64 + + +def purge_directory_contents(directory_fd: int, device: int, depth: int = 0) -> None: + """Empty an open directory fd-relative: no link is followed, no device crossed. + + For a directory the snapshot recorded without descendants (Git metadata), + where ``anchored_remove`` has no inventory to walk. A symlink is unlinked as + a link, never entered. + """ + if depth > MAX_PURGE_DEPTH: + raise HygieneError("directory contents nest too deeply to purge") + with os.scandir(directory_fd) as iterator: + children = [ + (child.name, child.is_dir(follow_symlinks=False)) for child in iterator + ] + for name, is_directory in children: + if not is_directory: + os.unlink(name, dir_fd=directory_fd) + continue + child_fd = os.open( + name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=directory_fd + ) + try: + if os.fstat(child_fd).st_dev != device: + raise HygieneError("directory contents cross a device boundary") + purge_directory_contents(child_fd, device, depth + 1) + finally: + os.close(child_fd) + os.rmdir(name, dir_fd=directory_fd) + + +def opaque_contents_blocker( + path: Path, target: Path, globs: list[str], mounts: set[Path] +) -> str | None: + """The reason a directory's uninventoried contents may not be purged, or None. + + The snapshot names nothing beneath Git metadata, so the protections + apply and handoff-verify check per entry are checked here per live path. + """ + if any(is_within(mount, path) for mount in mounts): + return "nested-mount-point" + for root, directories, files in os.walk(path, followlinks=False): + for name in (*directories, *files): + if consumer_path_protected(Path(root, name), target, globs): + return "consumer-protected-path" + return None + + def anchored_remove( target_fd: int, relative: str, entry: dict[str, Any], entries: dict[str, dict[str, Any]], target: Path, + *, + purge_contents: bool = False, ) -> None: parent_fd, name = open_anchored_parent(target_fd, relative, entries) try: @@ -4010,6 +4061,8 @@ def anchored_remove( current, opened ): raise HygieneError("anchored directory changed since the snapshot") + if purge_contents: + purge_directory_contents(directory_fd, opened.st_dev) with os.scandir(directory_fd) as iterator: if next(iterator, None) is not None: raise HygieneError("anchored directory is not empty") @@ -4212,6 +4265,243 @@ def apply_plan(snapshot: dict[str, Any], plan: dict[str, Any]) -> dict[str, Any] } +def handoff_apply_report( + target: Path, + relative: str, + verdict: dict[str, Any] | None, + removed: list[dict[str, Any]], + skipped: list[dict[str, str]], + *, + logical_removed: int = 0, + reclaimable_removed: int = 0, + free_space_delta: int = 0, +) -> dict[str, Any]: + """A handoff-apply report: ``blocked`` when nothing was removed.""" + status = ( + "completed" if not skipped else "completed-with-skips" if removed else "blocked" + ) + return { + "status": status, + "target": str(target), + "path": relative, + "verdict": verdict, + "accept_unpublished": (verdict or {}) + .get("vcs_evidence", {}) + .get("accept_unpublished", []), + "removed": removed, + "skipped": skipped, + "paths_removed": len(removed), + "empty_directories_removed": sum( + 1 for item in removed if item["empty_directory"] + ), + "logical_bytes_removed": logical_removed, + "reclaimable_local_bytes_removed": reclaimable_removed, + "observed_free_space_delta_bytes": free_space_delta, + } + + +def handoff_apply( + snapshot: dict[str, Any], + relative: str, + vcs_evidence: dict[str, dict[str, Any]], +) -> dict[str, Any]: + """Verify one approved path as handoff-verify does, then delete it on `clear`. + + The verdict is computed here, in this process, immediately before the + deletion: verdicts expire the moment they are emitted, so none is ever read + from a file. ``accept_unpublished`` is evaluated only inside + ``handoff_verify``; this lane never reads it. The one blocker a clear + verdict satisfies is ``vcs-tracked-content`` (and the ``.git`` marker + protections through ``evidence_adjusted_protections``), only under this + approved path and only when the verdict verified the repository evidence. + Every other check ``apply_plan`` runs still applies to each entry, and a + repository's Git metadata, which the snapshot records without descendants, + is emptied fd-relative before its own removal. + + There is no plan here, so no owner: this lane cannot express managed + state and the ``native-managed-report-only`` check has nothing to read. + """ + target = Path(snapshot["target"]).absolute() + entries = entry_map(snapshot) + platform_blockers = execution_blockers() + if platform_blockers: + return handoff_apply_report( + target, + relative, + None, + [], + [ + { + "path": relative, + "outcome": "protected", + "detail": ", ".join(platform_blockers), + } + ], + ) + verdict = handoff_verify(snapshot, [relative], vcs_evidence)["verdicts"][0] + if verdict["verdict"] != "clear": + return handoff_apply_report( + target, + relative, + verdict, + [], + [ + { + "path": relative, + "outcome": "protected", + "detail": f"{verdict['verdict']}: {', '.join(verdict['reasons'])}", + } + ], + ) + evidence = verdict.get("vcs_evidence", {}) + tracked_waived = evidence.get("status") == "verified" + repository_paths = [ + target.joinpath(*PurePosixPath(value).parts) + for value in evidence.get("repositories", []) + ] + git_metadata = {repository / GIT_METADATA_NAME for repository in repository_paths} + exact_names = baseline_protected_names() | set( + snapshot.get("policy", {}).get("protected_exact_names", []) + ) + globs = snapshot_protection_globs(snapshot) + + def path_blockers(path: Path, mounts: set[Path]) -> list[str]: + reasons = evidence_adjusted_protections( + hard_protection(path, target, exact_names, mounts), + path, + target, + repository_paths, + exact_names, + ) + if consumer_path_protected(path, target, globs): + reasons.append("consumer-protected-path") + # Git metadata holds no tracked content, and tracked_blocker cannot + # answer for it: `git rev-parse --show-toplevel` fails inside `.git`. + if path not in git_metadata: + try: + vcs = tracked_blocker(path, target) + except (OSError, subprocess.SubprocessError): + vcs = "vcs-state-unverified" + if vcs and not (tracked_waived and vcs == "vcs-tracked-content"): + reasons.append(vcs) + return sorted(set(reasons)) + + before = shutil.disk_usage(target).free + removed: list[dict[str, Any]] = [] + skipped: list[dict[str, str]] = [] + logical_removed = 0 + reclaimable_removed = 0 + target_fd = os.open(target, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + try: + if not same_object_identity(os.fstat(target_fd), snapshot["target_identity"]): + raise HygieneError("anchored target was replaced since the snapshot") + known_mounts, mount_error = linux_mount_points() + if mount_error: + raise HygieneError(mount_error) + candidate_blockers = path_blockers( + target.joinpath(*PurePosixPath(relative).parts), known_mounts + ) + if candidate_blockers: + return handoff_apply_report( + target, + relative, + verdict, + [], + [ + { + "path": relative, + "outcome": "protected", + "detail": ", ".join(candidate_blockers), + } + ], + ) + for name in removal_entries(relative, entries): + entry = entries[name] + path = target.joinpath(*PurePosixPath(name).parts) + if not same_removal_identity(path, entry) or is_linkish(path): + skipped.append({"path": name, "outcome": "changed-or-link"}) + continue + fresh_mounts, fresh_mount_error = linux_mount_points() + if fresh_mount_error: + skipped.append( + { + "path": name, + "outcome": "protected", + "detail": "mount-state-unverified", + } + ) + continue + purge = entry["kind"] == "directory" and path in git_metadata + fresh_blockers = path_blockers(path, fresh_mounts) + if purge and not fresh_blockers: + contents_blocker = opaque_contents_blocker( + path, target, globs, fresh_mounts + ) + fresh_blockers = [contents_blocker] if contents_blocker else [] + if fresh_blockers: + skipped.append( + { + "path": name, + "outcome": "protected", + "detail": ", ".join(fresh_blockers), + } + ) + continue + state, detail = handle_state(path) + if state != "clear": + outcome = {"open": "locked", "needs_elevation": "needs-elevation"}.get( + state, "handle-state-unverified" + ) + skipped.append( + {"path": name, "outcome": outcome, "detail": detail or ""} + ) + continue + try: + anchored_remove( + target_fd, name, entry, entries, target, purge_contents=purge + ) + except HygieneError as exc: + skipped.append( + {"path": name, "outcome": "changed-or-link", "detail": str(exc)} + ) + continue + except PermissionError as exc: + skipped.append( + {"path": name, "outcome": "needs-elevation", "detail": str(exc)} + ) + continue + except OSError as exc: + skipped.append( + {"path": name, "outcome": "delete-failed", "detail": str(exc)} + ) + continue + logical = entry_logical_file_bytes(entry) + reclaimable = entry_reclaimable_local_bytes(entry) or 0 + logical_removed += logical + reclaimable_removed += reclaimable + removed.append( + { + "path": name, + "empty_directory": entry_is_empty_directory(entry, entries), + "logical_bytes": logical, + "reclaimable_local_bytes": reclaimable, + **({"contents_purged": True} if purge else {}), + } + ) + finally: + os.close(target_fd) + return handoff_apply_report( + target, + relative, + verdict, + removed, + skipped, + logical_removed=logical_removed, + reclaimable_removed=reclaimable_removed, + free_space_delta=shutil.disk_usage(target).free - before, + ) + + _PARSER_VALUE_TYPES = {"int": int} @@ -4542,6 +4832,22 @@ def main(argv: list[str] | None = None) -> int: ) result = handoff_verify(snapshot, approved, vcs_evidence) return emit(result, 3 if handoff_verify_blocks(result) else 0) + if args.command == "handoff-apply": + if not args.execute: + raise HygieneError("handoff-apply requires the explicit --execute flag") + (approved,) = validate_handoff_paths( + {"version": SCHEMA_VERSION, "paths": [args.path]}, entry_map(snapshot) + ) + vcs_evidence = validate_vcs_evidence( + load_json(Path(args.vcs_evidence)), [approved] + ) + report_path = state_output_path(Path(args.report)) + report = handoff_apply(snapshot, approved, vcs_evidence) + write_json(report_path, report) + return emit( + report, + {"completed": 0, "completed-with-skips": 4}.get(report["status"], 3), + ) plan = load_json(Path(args.plan)) checked = preview(snapshot, plan) if args.command == "preview": diff --git a/plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py b/plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py index 7aa395e924..acc2db2121 100755 --- a/plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py +++ b/plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py @@ -6350,6 +6350,360 @@ def test_container_cascade_terminates_on_deeply_nested_input(self) -> None: self.assertEqual("level0", ordered[-1]) +linux_only = unittest.skipUnless( + hygiene.os_key() == "linux", "the in-engine deletion lane is Linux-only" +) +needs_git = unittest.skipUnless( + shutil.which("git"), "git is required for the VCS evidence fixture" +) + + +@linux_only +@needs_git +class HandoffApplyTests(unittest.TestCase): + """handoff-apply: verify one approved path in-process, delete only on `clear`.""" + + ACK_REASON = "throwaway test repo" + + def setUp(self) -> None: + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.base = Path(temporary.name) + self.target = self.base / "target" + self.target.mkdir() + (self.target / "keep.txt").write_text("keep\n", encoding="utf-8") + for patcher in ( + mock.patch.object(hygiene, "standing_policy_paths", return_value=[]), + mock.patch.object(hygiene, "handle_state", return_value=("clear", None)), + ): + self.addCleanup(patcher.stop) + patcher.start() + + def checkout(self, name: str = "checkout", *, commit: bool = True) -> Path: + return HandoffVerifyTests.create_throwaway( + self.target, commit=commit, name=name + ) + + def evidence(self, name: str = "checkout", *, accept: bool = True): + entry: dict[str, Any] = {"path": name, "remote": None, "stash_copies": []} + if accept: + entry |= {"accept_unpublished": True, "reason": self.ACK_REASON} + return {name: entry} + + def snapshot(self) -> dict[str, Any]: + return hygiene.scan_tree(self.target.resolve(), hygiene.load_policy(None)) + + def apply(self, snapshot, evidence, path: str = "checkout") -> dict[str, Any]: + return hygiene.handoff_apply(snapshot, path, evidence) + + def assert_nothing_removed(self, report, checkout: Path) -> None: + self.assertEqual("blocked", report["status"]) + self.assertEqual([], report["removed"]) + self.assertEqual(0, report["paths_removed"]) + self.assertTrue((checkout / "tracked.txt").exists()) + self.assertTrue((checkout / ".git").is_dir()) + self.assertTrue((self.target / "keep.txt").exists()) + + def test_acknowledged_checkout_is_deleted_and_the_report_shows_the_ack( + self, + ) -> None: + for commit in (False, True): + name = f"checkout-{'committed' if commit else 'empty'}" + with self.subTest(commit=commit): + checkout = self.checkout(name, commit=commit) + report = self.apply(self.snapshot(), self.evidence(name), name) + self.assertEqual("completed", report["status"], report["skipped"]) + self.assertEqual([], report["skipped"]) + self.assertFalse(checkout.exists()) + self.assertTrue((self.target / "keep.txt").exists()) + self.assertEqual("clear", report["verdict"]["verdict"]) + self.assertEqual( + [{"repository": name, "reason": self.ACK_REASON}], + report["accept_unpublished"], + ) + removed = [item["path"] for item in report["removed"]] + self.assertEqual(name, removed[-1]) + self.assertEqual(f"{name}/.git", removed[-2]) + self.assertTrue(report["removed"][-2]["contents_purged"]) + + def test_unacknowledged_checkout_stays_contested_and_nothing_is_removed( + self, + ) -> None: + checkout = self.checkout() + report = self.apply(self.snapshot(), self.evidence(accept=False)) + self.assert_nothing_removed(report, checkout) + self.assertEqual("contested", report["verdict"]["verdict"]) + self.assertEqual([], report["accept_unpublished"]) + detail = report["skipped"][0]["detail"] + self.assertIn("vcs-evidence-status-not-clean", detail) + self.assertIn("vcs-evidence-remote-not-declared", detail) + + def test_an_acknowledgement_for_another_path_authorizes_nothing(self) -> None: + acked = self.checkout("acked") + plain = self.checkout("plain") + snapshot = self.snapshot() + with self.assertRaisesRegex(hygiene.HygieneError, "outside approved paths"): + hygiene.validate_vcs_evidence( + {"version": 1, "repositories": list(self.evidence("acked").values())}, + ["plain"], + ) + report = self.apply(snapshot, self.evidence("plain", accept=False), "plain") + self.assert_nothing_removed(report, plain) + self.assertTrue(acked.exists()) + + def test_an_acknowledged_nested_repository_does_not_authorize_its_parent( + self, + ) -> None: + parent = self.checkout("parent") + HandoffVerifyTests.create_throwaway(parent, commit=True, name="sub") + evidence = self.evidence("parent", accept=False) + evidence |= self.evidence("parent/sub") + report = self.apply(self.snapshot(), evidence, "parent") + self.assert_nothing_removed(report, parent) + self.assertTrue((parent / "sub" / ".git").is_dir()) + + def test_an_unduplicated_stash_still_blocks_an_acknowledged_checkout(self) -> None: + checkout = self.checkout() + (checkout / "tracked.txt").write_text("stashed\n", encoding="utf-8") + subprocess.run( + ["git", "-C", str(checkout), "stash", "push", "-qm", "wip"], check=True + ) + report = self.apply(self.snapshot(), self.evidence()) + self.assert_nothing_removed(report, checkout) + self.assertEqual("contested", report["verdict"]["verdict"]) + self.assertIn( + "vcs-evidence-stash-not-duplicated", report["skipped"][0]["detail"] + ) + + def test_a_changed_descendant_set_or_identity_blocks(self) -> None: + def add_file(checkout: Path) -> None: + (checkout / "late.txt").write_text("added after scan\n", encoding="utf-8") + + def rewrite_file(checkout: Path) -> None: + (checkout / "tracked.txt").write_text("rewritten\n" * 3, encoding="utf-8") + + for name, change in (("descendants", add_file), ("identity", rewrite_file)): + with self.subTest(change=name): + checkout = self.checkout(name) + snapshot = self.snapshot() + change(checkout) + report = self.apply(snapshot, self.evidence(name), name) + self.assertEqual("blocked", report["status"]) + self.assertEqual("drifted", report["verdict"]["verdict"]) + self.assertEqual([], report["removed"]) + self.assertTrue((checkout / ".git").is_dir()) + self.assertTrue((checkout / "tracked.txt").exists()) + + def test_the_verdict_is_computed_in_process_for_exactly_the_one_path(self) -> None: + self.checkout() + snapshot = self.snapshot() + evidence = self.evidence() + with mock.patch.object( + hygiene, "handoff_verify", wraps=hygiene.handoff_verify + ) as verify: + self.apply(snapshot, evidence) + verify.assert_called_once_with(snapshot, ["checkout"], evidence) + + def test_preview_never_evaluates_the_acknowledgement(self) -> None: + self.checkout() + snapshot = self.snapshot() + plan = {"version": 1, "tier": "high", "candidates": [candidate("checkout")]} + with mock.patch.object( + hygiene, + "verify_vcs_checkout_evidence", + side_effect=AssertionError("preview must not evaluate evidence"), + ): + preview = hygiene.preview(snapshot, plan) + self.assertEqual("blocked", preview["status"]) + self.assertIn("truncated-not-inventoried", preview["candidates"][0]["blockers"]) + + def clear_verdict(self, snapshot, evidence, path: str = "checkout"): + return hygiene.handoff_verify(snapshot, [path], evidence) + + def test_every_non_vcs_check_still_runs_after_the_verdict(self) -> None: + checkout = self.checkout() + snapshot = self.snapshot() + evidence = self.evidence() + canned = self.clear_verdict(snapshot, evidence) + self.assertEqual("clear", canned["verdicts"][0]["verdict"]) + cases = { + "live-handle": ( + mock.patch.object( + hygiene, "handle_state", return_value=("open", "pid 1") + ), + "locked", + ), + "tracked-state-unverified": ( + mock.patch.object( + hygiene, "tracked_blocker", return_value="vcs-state-unverified" + ), + "protected", + ), + "git-not-found": ( + mock.patch.object( + hygiene, "tracked_blocker", return_value="git-not-found" + ), + "protected", + ), + } + for name, (patcher, outcome) in cases.items(): + with ( + self.subTest(case=name), + mock.patch.object(hygiene, "handoff_verify", return_value=canned), + patcher, + ): + report = self.apply(snapshot, evidence) + self.assertEqual("blocked", report["status"]) + self.assertEqual([], report["removed"]) + self.assertEqual(outcome, report["skipped"][0]["outcome"]) + self.assertTrue((checkout / ".git").is_dir()) + self.assertTrue((checkout / "tracked.txt").exists()) + + def test_an_entry_that_changes_after_the_verdict_is_skipped(self) -> None: + checkout = self.checkout() + snapshot = self.snapshot() + evidence = self.evidence() + canned = self.clear_verdict(snapshot, evidence) + (checkout / "untracked.txt").write_text("changed after verdict\n" * 2) + with mock.patch.object(hygiene, "handoff_verify", return_value=canned): + report = self.apply(snapshot, evidence) + self.assertEqual("completed-with-skips", report["status"]) + self.assertEqual( + ["checkout/untracked.txt", "checkout"], + [item["path"] for item in report["skipped"]], + ) + self.assertTrue((checkout / "untracked.txt").exists()) + self.assertTrue(checkout.is_dir()) + + def test_a_mount_inside_git_metadata_blocks_before_anything_is_removed( + self, + ) -> None: + checkout = self.checkout() + snapshot = self.snapshot() + evidence = self.evidence() + canned = self.clear_verdict(snapshot, evidence) + mounted = (self.target.resolve() / "checkout" / ".git" / "hooks").absolute() + real, _ = hygiene.linux_mount_points() + with ( + mock.patch.object(hygiene, "handoff_verify", return_value=canned), + mock.patch.object( + hygiene, "linux_mount_points", return_value=(real | {mounted}, None) + ), + ): + report = self.apply(snapshot, evidence) + skipped = {item["path"]: item for item in report["skipped"]} + self.assertIn("nested-mount-point", skipped["checkout/.git"]["detail"]) + self.assertTrue((checkout / ".git" / "hooks").is_dir()) + self.assertTrue(checkout.is_dir()) + + def test_a_consumer_glob_inside_git_metadata_blocks_the_purge(self) -> None: + checkout = self.checkout() + snapshot = self.snapshot() + snapshot["policy"]["additional_protected_path_globs"] = [ + "checkout/.git/hooks/*" + ] + report = self.apply(snapshot, self.evidence()) + skipped = {item["path"]: item for item in report["skipped"]} + self.assertEqual("consumer-protected-path", skipped["checkout/.git"]["detail"]) + self.assertTrue((checkout / ".git" / "hooks").is_dir()) + + def test_a_link_inside_git_metadata_is_unlinked_not_followed(self) -> None: + checkout = self.checkout() + outside = self.base / "outside" + outside.mkdir() + (outside / "precious.txt").write_text("precious\n", encoding="utf-8") + (checkout / ".git" / "escape").symlink_to(outside) + report = self.apply(self.snapshot(), self.evidence()) + self.assertEqual("completed", report["status"], report["skipped"]) + self.assertFalse(checkout.exists()) + self.assertEqual("precious\n", (outside / "precious.txt").read_text("utf-8")) + + def cli(self, snapshot, evidence, *extra: str) -> tuple[int, dict[str, Any]]: + (self.base / "snapshot.json").write_text(json.dumps(snapshot), "utf-8") + (self.base / "evidence.json").write_text( + json.dumps({"version": 1, "repositories": list(evidence.values())}), "utf-8" + ) + argv = [ + "handoff-apply", + "--snapshot", + str(self.base / "snapshot.json"), + "--path", + "checkout", + "--vcs-evidence", + str(self.base / "evidence.json"), + "--report", + str(self.base / "report.json"), + "--data-root", + str(self.base), + *extra, + ] + output = io.StringIO() + with redirect_stdout(output): + status = hygiene.main(argv) + return status, json.loads(output.getvalue()) + + def test_cli_deletes_an_acknowledged_checkout_and_writes_the_report(self) -> None: + checkout = self.checkout() + status, payload = self.cli(self.snapshot(), self.evidence(), "--execute") + self.assertEqual(0, status) + self.assertEqual("completed", payload["status"]) + self.assertFalse(checkout.exists()) + written = json.loads((self.base / "report.json").read_text("utf-8")) + self.assertEqual( + [{"repository": "checkout", "reason": self.ACK_REASON}], + written["accept_unpublished"], + ) + + def test_cli_requires_execute_and_removes_nothing_without_it(self) -> None: + checkout = self.checkout() + status, payload = self.cli(self.snapshot(), self.evidence()) + self.assertEqual(2, status) + self.assertEqual("invalid-or-blocked", payload["status"]) + self.assertIn("--execute", payload["error"]) + self.assertTrue((checkout / ".git").is_dir()) + + def test_cli_exits_three_for_an_unacknowledged_checkout(self) -> None: + checkout = self.checkout() + status, payload = self.cli( + self.snapshot(), self.evidence(accept=False), "--execute" + ) + self.assertEqual(3, status) + self.assertEqual("blocked", payload["status"]) + self.assertTrue((checkout / ".git").is_dir()) + + def test_cli_rejects_evidence_naming_a_path_other_than_the_approved_one( + self, + ) -> None: + checkout = self.checkout() + other = self.checkout("other") + status, payload = self.cli(self.snapshot(), self.evidence("other"), "--execute") + self.assertEqual(2, status) + self.assertIn("outside approved paths", payload["error"]) + self.assertTrue((checkout / ".git").is_dir()) + self.assertTrue((other / ".git").is_dir()) + + +class HandoffApplyPlatformTests(unittest.TestCase): + def test_a_non_linux_host_refuses_before_verifying_anything(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) / "target" + root.mkdir() + junk = root / "junk.tmp" + junk.write_text("stale", encoding="utf-8") + snapshot = hygiene.scan_tree(root.resolve(), hygiene.load_policy(None)) + with ( + mock.patch.object(hygiene, "os_key", return_value="macos"), + mock.patch.object(hygiene, "handoff_verify") as verify, + ): + report = hygiene.handoff_apply(snapshot, "junk.tmp", {}) + verify.assert_not_called() + self.assertEqual("blocked", report["status"]) + self.assertEqual([], report["removed"]) + self.assertIsNone(report["verdict"]) + self.assertEqual(hygiene.PLATFORM_BLOCKER, report["skipped"][0]["detail"]) + self.assertTrue(junk.exists()) + + class _ClosedPipeStderr(io.StringIO): """A stderr stand-in whose writes fail the way a lost hook-host pipe does.""" From 0413c5de021ee59f7cf7412e98a3341600cf2362 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:39:59 -0400 Subject: [PATCH 2/8] fix(disk-hygiene): check inside a checkout's .git before handoff-apply removes anything handoff-apply deletes a directory's entries deepest first, so the working tree went before .git. The mount, consumer-glob and unreadable-directory checks on the uninventoried contents of .git ran only when .git itself was reached, which left a gutted checkout with .git intact. Run those checks for every verified repository's .git before the first removal, and keep the per-entry recheck as the race defense. A directory the walk cannot read now blocks as needs-elevation or filesystem-state-unverified instead of being skipped. The relaxed .git marker protections apply only when the verdict verified the repository evidence. Refs #5178 Co-Authored-By: Claude Sonnet 5.5 --- .../skills/clean/scripts/hygiene.py | 29 +++++++++++++--- .../skills/clean/scripts/test_hygiene.py | 33 ++++++++++++++----- 2 files changed, 50 insertions(+), 12 deletions(-) diff --git a/plugins/disk-hygiene/skills/clean/scripts/hygiene.py b/plugins/disk-hygiene/skills/clean/scripts/hygiene.py index 7251d33de8..45fed4d357 100755 --- a/plugins/disk-hygiene/skills/clean/scripts/hygiene.py +++ b/plugins/disk-hygiene/skills/clean/scripts/hygiene.py @@ -4022,10 +4022,19 @@ def opaque_contents_blocker( """ if any(is_within(mount, path) for mount in mounts): return "nested-mount-point" - for root, directories, files in os.walk(path, followlinks=False): - for name in (*directories, *files): - if consumer_path_protected(Path(root, name), target, globs): - return "consumer-protected-path" + + def unreadable(error: OSError) -> None: + raise error + + try: + for root, directories, files in os.walk(path, onerror=unreadable): + for name in (*directories, *files): + if consumer_path_protected(Path(root, name), target, globs): + return "consumer-protected-path" + except PermissionError: + return "needs-elevation" + except OSError: + return "filesystem-state-unverified" return None @@ -4358,6 +4367,7 @@ def handoff_apply( repository_paths = [ target.joinpath(*PurePosixPath(value).parts) for value in evidence.get("repositories", []) + if tracked_waived ] git_metadata = {repository / GIT_METADATA_NAME for repository in repository_paths} exact_names = baseline_protected_names() | set( @@ -4401,6 +4411,17 @@ def path_blockers(path: Path, mounts: set[Path]) -> list[str]: candidate_blockers = path_blockers( target.joinpath(*PurePosixPath(relative).parts), known_mounts ) + # Git metadata is deleted after the working tree, so what sits inside it + # is checked now, before anything is removed. + candidate_blockers += [ + blocker + for metadata in sorted(git_metadata) + if ( + blocker := opaque_contents_blocker( + metadata, target, globs, known_mounts + ) + ) + ] if candidate_blockers: return handoff_apply_report( target, diff --git a/plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py b/plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py index acc2db2121..e38bf3a2d2 100755 --- a/plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py +++ b/plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py @@ -6591,21 +6591,38 @@ def test_a_mount_inside_git_metadata_blocks_before_anything_is_removed( ), ): report = self.apply(snapshot, evidence) - skipped = {item["path"]: item for item in report["skipped"]} - self.assertIn("nested-mount-point", skipped["checkout/.git"]["detail"]) - self.assertTrue((checkout / ".git" / "hooks").is_dir()) - self.assertTrue(checkout.is_dir()) + self.assert_nothing_removed(report, checkout) + self.assertEqual("nested-mount-point", report["skipped"][0]["detail"]) + self.assertTrue((checkout / "untracked.txt").exists()) - def test_a_consumer_glob_inside_git_metadata_blocks_the_purge(self) -> None: + def test_a_consumer_glob_inside_git_metadata_blocks_before_anything_is_removed( + self, + ) -> None: checkout = self.checkout() snapshot = self.snapshot() snapshot["policy"]["additional_protected_path_globs"] = [ "checkout/.git/hooks/*" ] report = self.apply(snapshot, self.evidence()) - skipped = {item["path"]: item for item in report["skipped"]} - self.assertEqual("consumer-protected-path", skipped["checkout/.git"]["detail"]) - self.assertTrue((checkout / ".git" / "hooks").is_dir()) + self.assert_nothing_removed(report, checkout) + self.assertEqual("consumer-protected-path", report["skipped"][0]["detail"]) + self.assertTrue((checkout / "untracked.txt").exists()) + + @unittest.skipIf( + hasattr(os, "geteuid") and os.geteuid() == 0, "root reads every directory" + ) + def test_an_unreadable_directory_inside_git_metadata_blocks_the_purge( + self, + ) -> None: + checkout = self.checkout() + snapshot = self.snapshot() + hooks = checkout / ".git" / "hooks" + hooks.chmod(0) + self.addCleanup(hooks.chmod, 0o755) + report = self.apply(snapshot, self.evidence()) + self.assert_nothing_removed(report, checkout) + self.assertEqual("needs-elevation", report["skipped"][0]["detail"]) + self.assertTrue((checkout / "untracked.txt").exists()) def test_a_link_inside_git_metadata_is_unlinked_not_followed(self) -> None: checkout = self.checkout() From 733dad1712c343408a57f285758fbbefdf9ecad4 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:47:36 -0400 Subject: [PATCH 3/8] feat(disk-hygiene): guard asks for the exact handoff-apply shape handoff-apply was declared in the grammar, so the classifier recognized it, but _decide had no branch for it and denied it as not an exact engine command. The guard now sorts every admitted subcommand into a hand-placed read-only or mutating set. A mutating subcommand gets the hook-issued ask when execution is enabled and a kill-switch denial, under its own rule, when it is not. The exact handoff-apply shape asks with a prompt naming the one approved path and the acknowledged loss of unpushed commits and untracked or ignored files. The kill-switch denial text lists the read-only subcommands in grammar order instead of a hand-written list. A newly declared subcommand stays denied until it is placed in one set; a test fails until every grammar subcommand is in exactly one. Tests cover the ask, each malformed variant (missing --vcs-evidence, --plan or --approval-token beside --path, repeated --path, a flag-shaped value, missing or unauthorized --data-root, missing --execute), the kill switch, the recorded rules, and unchanged apply, preview and handoff-verify verdicts. Co-Authored-By: Claude Opus 5.5 --- .../skills/clean/scripts/destructive_guard.py | 52 +++++++-- .../skills/clean/scripts/test_hygiene.py | 109 ++++++++++++++++++ 2 files changed, 150 insertions(+), 11 deletions(-) diff --git a/plugins/disk-hygiene/skills/clean/scripts/destructive_guard.py b/plugins/disk-hygiene/skills/clean/scripts/destructive_guard.py index 8e68624b8a..2fde87bd9c 100755 --- a/plugins/disk-hygiene/skills/clean/scripts/destructive_guard.py +++ b/plugins/disk-hygiene/skills/clean/scripts/destructive_guard.py @@ -569,6 +569,25 @@ def _within_plugin_cache_family(value: str) -> bool: # before it matches flags, so an unknown subcommand fails closed. _ALLOWED_ENGINE_SUBCOMMANDS = engine_grammar.SUBCOMMAND_NAMES +# The verdict `_decide` gives each admitted subcommand. Placed by hand, not +# derived from the grammar: a newly declared subcommand is still denied until +# someone decides whether it is read-only or a mutation that needs the prompt. +_READONLY_ENGINE_SUBCOMMANDS = frozenset({"scan", "preview", "handoff-verify"}) +_MUTATING_ENGINE_SUBCOMMANDS = frozenset({"apply", "handoff-apply"}) +_MUTATION_PROMPTS = { + "apply": ( + "disk-hygiene is ready to apply one exact, previewed tier. Confirm this " + "final mutation prompt only if it matches the tier and paths you just " + "approved." + ), + "handoff-apply": ( + "disk-hygiene is ready to verify and delete one exact approved path, " + "including version-control content you acknowledged losing: unpushed " + "commits and untracked or ignored files do not come back. Confirm this " + "final mutation prompt only if it is the one path you just approved." + ), +} + def _engine_script_path() -> Path: """The one bundled engine path both the classifier and the denial disclose.""" @@ -2402,7 +2421,7 @@ def _decide(command: str, tool_name: str, start: float) -> int: "(disk-hygiene belt inspection allowlist).", ) command_kind = classify_exact_engine_command(command, authority) - if command_kind in {"scan", "preview", "handoff-verify"}: + if command_kind in _READONLY_ENGINE_SUBCOMMANDS: return _settle( command, tool_name, @@ -2411,27 +2430,38 @@ def _decide(command: str, tool_name: str, start: float) -> int: f"exact-engine-{command_kind}", "Exact bundled disk-hygiene read-only gate invocation.", ) - if command_kind == "apply" and enabled: + if command_kind in _MUTATING_ENGINE_SUBCOMMANDS and enabled: return _settle( command, tool_name, start, "ask", - "exact-engine-apply", - "disk-hygiene is ready to apply one exact, previewed tier. Confirm this final mutation prompt only if it matches the tier and paths you just approved.", + f"exact-engine-{command_kind}", + _MUTATION_PROMPTS[command_kind], + ) + if command_kind in _MUTATING_ENGINE_SUBCOMMANDS: + readonly = [ + name + for name in _ALLOWED_ENGINE_SUBCOMMANDS + if name in _READONLY_ENGINE_SUBCOMMANDS + ] + return _settle( + command, + tool_name, + start, + "deny", + f"kill-switch-disabled-{command_kind}", + "Disk-hygiene execution is disabled; only exact bundled " + f"{', '.join(readonly[:-1])}, and {readonly[-1]} invocations are " + "permitted.", ) - denied_by_kill_switch = command_kind == "apply" return _settle( command, tool_name, start, "deny", - "kill-switch-disabled-apply" - if denied_by_kill_switch - else "not-exact-engine-command", - "Disk-hygiene execution is disabled; only exact bundled scan, preview, and handoff-verify invocations are permitted." - if denied_by_kill_switch - else _bash_denial_guidance(authority), + "not-exact-engine-command", + _bash_denial_guidance(authority), ) diff --git a/plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py b/plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py index e38bf3a2d2..ca5e974464 100755 --- a/plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py +++ b/plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py @@ -6916,6 +6916,95 @@ def test_disabled_guard_denies_exact_apply(self) -> None: result["hookSpecificOutput"]["permissionDecisionReason"], ) + def _handoff_apply_command(self, tail: str | None = None) -> str: + script = SCRIPT_DIR / "hygiene.py" + if tail is None: + tail = ( + "--execute --snapshot s --path rel/junk --vcs-evidence e " + "--report r" + self.authorize_data_root() + ) + return f'"{self.python_command()}" "{script}" handoff-apply {tail}' + + def test_guard_forces_final_prompt_for_exact_handoff_apply(self) -> None: + result = self.run_guard(self._handoff_apply_command()) + output = result["hookSpecificOutput"] + self.assertEqual("ask", output["permissionDecision"]) + self.assertIn("one exact approved path", output["permissionDecisionReason"]) + + def test_guard_denies_every_malformed_handoff_apply(self) -> None: + data_root = self.authorize_data_root() + head = "--execute --snapshot s --path rel/junk" + tails = { + "no --vcs-evidence": f"{head} --report r{data_root}", + "--plan beside --path": f"{head} --vcs-evidence e --report r --plan p{data_root}", + "--approval-token beside --path": ( + f"{head} --vcs-evidence e --report r " + f"--approval-token {'a' * 24}{data_root}" + ), + "repeated --path": f"{head} --path rel/other --vcs-evidence e --report r{data_root}", + "flag-shaped value": ( + f"--execute --snapshot s --path -rf --vcs-evidence e --report r{data_root}" + ), + "no --data-root": f"{head} --vcs-evidence e --report r", + "unauthorized --data-root": ( + f'{head} --vcs-evidence e --report r --data-root "/somewhere/else"' + ), + "no --execute": f"--snapshot s --path rel/junk --vcs-evidence e --report r{data_root}", + } + for label, tail in tails.items(): + with self.subTest(label): + result = self.run_guard(self._handoff_apply_command(tail)) + self.assertEqual( + "deny", result["hookSpecificOutput"]["permissionDecision"] + ) + + def test_disabled_guard_denies_exact_handoff_apply(self) -> None: + result = self.run_guard_disabled(self._handoff_apply_command()) + output = result["hookSpecificOutput"] + self.assertEqual("deny", output["permissionDecision"]) + reason = output["permissionDecisionReason"] + self.assertIn("execution is disabled", reason) + self.assertIn("scan, preview, and handoff-verify invocations", reason) + self.assertNotIn("handoff-apply", reason) + + def test_every_grammar_subcommand_has_exactly_one_verdict_class(self) -> None: + readonly = guard._READONLY_ENGINE_SUBCOMMANDS + mutating = guard._MUTATING_ENGINE_SUBCOMMANDS + self.assertFalse(readonly & mutating) + self.assertEqual(set(guard._ALLOWED_ENGINE_SUBCOMMANDS), readonly | mutating) + self.assertEqual(set(mutating), set(guard._MUTATION_PROMPTS)) + + def test_existing_engine_verdicts_hold_beside_handoff_apply(self) -> None: + script = SCRIPT_DIR / "hygiene.py" + python = self.python_command() + data_root = self.authorize_data_root() + cases = { + "apply": ( + f"apply --execute --snapshot s --plan p --confirm-tier high " + f"--approval-token {'a' * 24} --report r", + "ask", + "deny", + ), + "preview": ("preview --snapshot s --plan p", "allow", "allow"), + "handoff-verify --path": ( + "handoff-verify --snapshot s --path rel/junk --vcs-evidence e", + "allow", + "allow", + ), + "handoff-verify --paths": ( + "handoff-verify --snapshot s --paths p.json", + "allow", + "allow", + ), + } + for label, (tail, when_enabled, when_disabled) in cases.items(): + command = f'"{python}" "{script}" {tail}{data_root}' + with self.subTest(label): + enabled = self.run_guard(command)["hookSpecificOutput"] + disabled = self.run_guard_disabled(command)["hookSpecificOutput"] + self.assertEqual(when_enabled, enabled["permissionDecision"]) + self.assertEqual(when_disabled, disabled["permissionDecision"]) + def test_guard_denies_apply_through_another_engine_path(self) -> None: command = f'"{self.python_command()}" C:/tmp/hygiene.py apply --execute --snapshot s --plan p --confirm-tier high --approval-token {"a" * 24} --report r' command += self.authorize_data_root() @@ -7314,6 +7403,26 @@ def test_kill_switch_denial_is_recorded_under_its_own_rule(self) -> None: (entry,) = self.decision_records() self.assertEqual("kill-switch-disabled-apply", entry["rule"]) + def test_handoff_apply_verdicts_are_recorded_under_their_own_rules(self) -> None: + script = (SCRIPT_DIR / "hygiene.py").resolve().as_posix() + root = self._data_root.resolve().as_posix() + command = ( + f'"{self.python_command()}" "{script}" handoff-apply --execute ' + "--snapshot s --path rel/junk --vcs-evidence e --report r " + f'--data-root "{root}"' + ) + for enabled, verdict, rule in ( + (True, "ask", "exact-engine-handoff-apply"), + (False, "deny", "kill-switch-disabled-handoff-apply"), + ): + with self.subTest(enabled=enabled): + result = self.run_guard_engine_gate(command, "Bash", enabled=enabled) + assert result is not None + self.assertEqual( + verdict, result["hookSpecificOutput"]["permissionDecision"] + ) + self.assertEqual(rule, self.decision_records()[-1]["rule"]) + def test_engine_gate_defer_records_nothing(self) -> None: """The always-on hot path stays free: no decision, no record, no cost.""" self.assertIsNone(self.run_guard_engine_gate("git status --short")) From e33a65fdf49f42a03e78c31ff6dc0882decf31a0 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:01:21 -0400 Subject: [PATCH 4/8] docs(disk-hygiene): document the Linux handoff-apply route and release 0.29.0 Co-Authored-By: Claude Opus 5.5 --- .../disk-hygiene/.claude-plugin/plugin.json | 2 +- plugins/disk-hygiene/CHANGELOG.md | 10 ++++ plugins/disk-hygiene/skills/clean/SKILL.md | 49 +++++++++++-------- .../skills/clean/reference/safety-model.md | 13 +++-- .../reference/unsupported-platform-handoff.md | 4 +- .../skills/clean/scripts/test_hygiene.py | 3 ++ 6 files changed, 54 insertions(+), 27 deletions(-) diff --git a/plugins/disk-hygiene/.claude-plugin/plugin.json b/plugins/disk-hygiene/.claude-plugin/plugin.json index eba7717009..066b53f004 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.28.19", + "version": "0.29.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 2e535d9ec2..eb544bc611 100644 --- a/plugins/disk-hygiene/CHANGELOG.md +++ b/plugins/disk-hygiene/CHANGELOG.md @@ -3,6 +3,16 @@ 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.29.0] - 2026-09-29 + +### Added + +- **`handoff-apply`, a Linux route for an acknowledged throwaway checkout.** For one exact approved path with `accept_unpublished` and the operator's reason in `vcs-evidence.json`, `handoff-apply --execute` re-runs `handoff-verify` in the same process and deletes only on a `clear` verdict, so a contested checkout no longer has to be removed outside the engine. Preview and token apply keep VCS protection categorical; the acknowledgement is evaluated only in the handoff verification path. The Bash guard asks for the exact `handoff-apply` shape. Windows and macOS keep `handoff-verify` and the manual handoff lane. + +### Changed + +- **clean docs:** `SKILL.md` section 6 carries the `handoff-apply` command block and names the Linux route in the throwaway-checkout bullet, still telling the operator plainly that unpushed commits and untracked or ignored files will be lost. `safety-model.md` and `unsupported-platform-handoff.md` no longer imply the acknowledgement has no Linux route. + ## [0.28.19] - 2026-09-29 ### Changed diff --git a/plugins/disk-hygiene/skills/clean/SKILL.md b/plugins/disk-hygiene/skills/clean/SKILL.md index 111284fd96..6b65484d05 100644 --- a/plugins/disk-hygiene/skills/clean/SKILL.md +++ b/plugins/disk-hygiene/skills/clean/SKILL.md @@ -96,11 +96,12 @@ blocked target, 3 when elevation is needed or filesystem state could not be veri approved checkout. When the operator wants a `contested` throwaway checkout gone anyway (no remote, untracked files, no commits), never delete it outside the engine. Record `accept_unpublished` with the operator's reason for that exact approved path in - `vcs-evidence.json`, run `handoff-verify`, and delete only on a `clear` verdict through the §6 - manual handoff lane: every other contest reason must be gone. Preview and apply keep VCS - protection categorical; the acknowledgement exists only in `handoff-verify`. Before deleting, - tell the operator plainly that unpushed commits and untracked or ignored files in that checkout - will be lost. + `vcs-evidence.json`; every other contest reason must be gone. On Linux, run the engine route in + §6 (`handoff-apply`): it re-runs `handoff-verify` and deletes only on a `clear` verdict. On + Windows and macOS, run `handoff-verify`, and delete only on a `clear` verdict through the §6 + manual handoff lane. Preview and token apply keep VCS protection categorical; the + acknowledgement exists only in `handoff-verify`. Before deleting, tell the 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. @@ -424,24 +425,32 @@ Report `reclaimable_local_bytes_removed` and the observed free-space delta **aft figures, never as the headline. Do not claim the observed free-space delta is exact: concurrent disk activity, sparse files, hard links, compression, and delayed allocation affect it. +### Acknowledged throwaway checkout (Linux) + +For one approved checkout with an [`accept_unpublished` entry](reference/safety-model.md#standalone-git-checkout-evidence), run only: + +```text +"" "${CLAUDE_PLUGIN_ROOT}/skills/clean/scripts/hygiene.py" handoff-apply --execute \ + --snapshot "/snapshot.json" --path "relative/checkout" \ + --vcs-evidence "/vcs-evidence.json" --report "/report-handoff.json" \ + --data-root "${CLAUDE_PLUGIN_DATA}" +``` + +One path per call; any verdict but `clear` removes nothing; confirm the guard's `ask` only for that path. + ### Unsupported-platform handoff (Windows, macOS) -Preview reports `execution-platform-unsupported` as a per-candidate blocker on Windows and macOS, -so the engine never deletes there and the default outcome is the report. When, and only when, -an execution request was made on one of those platforms and the human approved an exact single-tier -path list in this session, read -[reference/unsupported-platform-handoff.md](reference/unsupported-platform-handoff.md) and follow -it. It owns the approved-path forms (inline `--path`, or `handoff-paths.json`), the per-path -revalidation, and the hook belt that outlives the cleanup. Do not improvise a manual deletion -lane from the engine steps above. +Preview reports `execution-platform-unsupported` on Windows and macOS, so the engine never deletes +there and the default outcome is the report. After an execution request and human approval of an +exact single-tier path list, follow [the handoff reference](reference/unsupported-platform-handoff.md); never improvise. ## Gotchas Harness mechanics live in one copy, in the safety model, so a fix there cannot leave a stale -restatement behind here. Load [the safety model](reference/safety-model.md) when you need -them: how the guard registers on two surfaces, how the kill switch is delivered and scoped, and -what the PowerShell lane flags → "Kill-switch enforcement"; how the hooks launch, what that bounds, -and what the guard does when no Python resolves → "Hook launch form". +restatement behind here. Load [the safety model](reference/safety-model.md) for how the guard +registers on two surfaces, how the kill switch is delivered and scoped, and what the PowerShell lane +flags → "Kill-switch enforcement"; how the hooks launch and what the guard does when no Python +resolves → "Hook launch form". - POSIX permits unlinking an open file, so successful deletion is not a live-handle check. Linux execution requires an authoritative `lsof` result and fails closed on diagnostics or missing access. @@ -459,9 +468,9 @@ and what the guard does when no Python resolves → "Hook launch form". snapshot token exists. - `allowed-tools` would pre-approve rather than restrict tools, so this destructive skill intentionally grants none. Consumer permission policy remains authoritative. -- The Bash lane is deny-by-default: only the literal-word bundled scan, preview, handoff-verify, and - apply shapes (plus the argument-free kill-switch probe) pass, using the hook runtime's own absolute - interpreter. The same denial text also admits literal-form read-only supporting commands whose +- The Bash lane is deny-by-default: only the literal-word bundled scan, preview, handoff-verify, + apply, and handoff-apply shapes (plus the argument-free kill-switch probe) pass, using the hook + runtime's own absolute interpreter. The same denial text also admits literal-form read-only supporting commands whose heads are absolute paths under a trusted system directory: `[`, `basename`, `dirname`, `du`, `file`, `find`, `ls`, `pwd`, `stat`, `test` (`[` only as a complete `/usr/bin/[ ... ]` expression; `find` without `-delete`/`-exec`/`-ok`/`-fprint`). Bare names are denied because diff --git a/plugins/disk-hygiene/skills/clean/reference/safety-model.md b/plugins/disk-hygiene/skills/clean/reference/safety-model.md index e33e9faf2e..e84e93ee0e 100644 --- a/plugins/disk-hygiene/skills/clean/reference/safety-model.md +++ b/plugins/disk-hygiene/skills/clean/reference/safety-model.md @@ -64,8 +64,8 @@ bounded conditions. confirm), and deletion stays gated by the preview and per-tier approval; - the audit root itself is never a removal candidate; no protected shell-folder root, OS registry/profile hive, VCS metadata or tracked file, except that the read-only manual-handoff - verifier may classify a whole standalone Git checkout `clear` under the complete evidence bundle - below; + verifier, and the Linux `handoff-apply` that runs it, may classify a whole standalone Git + checkout `clear` under the complete evidence bundle below; - no symlink, Windows reparse traversal, non-root mount target, nested mount, or Linux bind mount (a volume root is itself a mount point and is governed by the OS-managed/confirmation reasoning above, not this structural mount veto); @@ -216,9 +216,12 @@ engine with every check skipped. Before deleting under it, tell the operator tha and untracked or ignored files in the checkout will be lost. Passing this bundle does not relax any non-Git protected name, non-Git VCS marker, mount, -link/reparse, consumer protection, identity/descendant, or live-handle check. The mode is read-only; -deletion remains a per-path manual handoff under the existing hook-issued `ask`, and the -verdict still expires immediately. +link/reparse, consumer protection, identity/descendant, or live-handle check. `handoff-verify` with +evidence is read-only. On Windows and macOS, deletion remains a per-path manual handoff under the +existing hook-issued `ask`. On Linux, `handoff-apply` consumes the same evidence file for one +approved path, runs the `handoff-verify` checks in the same process, and deletes only on `clear`, +under the same `ask`; preview and token apply never evaluate the acknowledgement. The verdict still +expires immediately. | Verdict | Meaning | Manual-lane action | |---|---|---| diff --git a/plugins/disk-hygiene/skills/clean/reference/unsupported-platform-handoff.md b/plugins/disk-hygiene/skills/clean/reference/unsupported-platform-handoff.md index 2e62ee007e..095dc94da2 100644 --- a/plugins/disk-hygiene/skills/clean/reference/unsupported-platform-handoff.md +++ b/plugins/disk-hygiene/skills/clean/reference/unsupported-platform-handoff.md @@ -108,7 +108,9 @@ engine plan: `"accept_unpublished": true` and the operator's `"reason"` to the entry whose `path` is the exact approved path, and tell the operator that unpushed commits and untracked or ignored files in it will be lost. The acknowledgement relaxes only those two gates; see - [the safety model](safety-model.md#standalone-git-checkout-evidence). Then run: + [the safety model](safety-model.md#standalone-git-checkout-evidence). This lane is for Windows + and macOS; on Linux the engine's `handoff-apply` (SKILL.md section 6) reads the same evidence + file and does the verify and the deletion in one process. Then run: ```text "" "${CLAUDE_PLUGIN_ROOT}/skills/clean/scripts/hygiene.py" handoff-verify \ diff --git a/plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py b/plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py index ca5e974464..c33df836e7 100755 --- a/plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py +++ b/plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py @@ -5362,6 +5362,9 @@ def test_skill_requires_handoff_verify_and_loss_warning(self) -> None: for phrase in ( "never delete it outside the engine", "run `handoff-verify`, and delete only on a `clear` verdict", + "On Linux, run the engine route in §6 (`handoff-apply`): it re-runs " + "`handoff-verify` and deletes only on a `clear` verdict", + "handoff-apply --execute", "tell the operator plainly that unpushed commits and untracked or " "ignored files in that checkout will be lost", ): From 9a2fda803115262e4f5c2c54f8419a14d817544f Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:24:57 -0400 Subject: [PATCH 5/8] docs(disk-hygiene): make the handoff-apply docs match what the engine does handoff-apply is not a read-only evidence mode and not acknowledgement-only: it deletes on any clear handoff-verify verdict, asks in the guard alongside apply, and is the one lane that empties Git metadata contents the snapshot never inventoried. - README: the VCS exception, the guard's two ask shapes, the snapshot-only traversal claim and the standalone-checkout pointer now name handoff-apply. - safety-model: state the purge and the checks that bound it (mount, consumer glob over a live walk, unreadable directory, per-directory device, links unlinked not followed), that the hard-protection name check is not applied to those contents, and drop the stale shape count and apply-only prompt claim. - SKILL.md: section 6 says any clear verdict deletes; restore the unsupported-platform and Gotchas wording the route had compressed. - CHANGELOG: describe the purge and the any-clear-verdict semantics. - opaque_contents_blocker docstring lists what it checks; add a test that a fully verified checkout is deleted without an acknowledgement. Co-Authored-By: Claude Sonnet 5.5 --- plugins/disk-hygiene/CHANGELOG.md | 29 ++++++++-- plugins/disk-hygiene/README.md | 53 +++++++++++-------- plugins/disk-hygiene/skills/clean/SKILL.md | 43 ++++++++------- .../clean/reference/fan-out-worker-brief.md | 4 +- .../skills/clean/reference/safety-model.md | 33 +++++++++--- .../skills/clean/scripts/hygiene.py | 5 +- .../skills/clean/scripts/test_hygiene.py | 16 ++++++ 7 files changed, 127 insertions(+), 56 deletions(-) diff --git a/plugins/disk-hygiene/CHANGELOG.md b/plugins/disk-hygiene/CHANGELOG.md index 0c3a6c6e1d..00fcd676b0 100644 --- a/plugins/disk-hygiene/CHANGELOG.md +++ b/plugins/disk-hygiene/CHANGELOG.md @@ -7,11 +7,30 @@ All notable changes to the `disk-hygiene` plugin are documented here. Format fol ### Added -- **`handoff-apply`, a Linux route for an acknowledged throwaway checkout.** For one exact approved path with `accept_unpublished` and the operator's reason in `vcs-evidence.json`, `handoff-apply --execute` re-runs `handoff-verify` in the same process and deletes only on a `clear` verdict, so a contested checkout no longer has to be removed outside the engine. Preview and token apply keep VCS protection categorical; the acknowledgement is evaluated only in the handoff verification path. The Bash guard asks for the exact `handoff-apply` shape. Windows and macOS keep `handoff-verify` and the manual handoff lane. - -### Changed - -- **clean docs:** `SKILL.md` section 6 carries the `handoff-apply` command block and names the Linux route in the throwaway-checkout bullet, still telling the operator plainly that unpushed commits and untracked or ignored files will be lost. `safety-model.md` and `unsupported-platform-handoff.md` no longer imply the acknowledgement has no Linux route. +- **`handoff-apply`, a Linux route for a standalone Git checkout** + ([#5178](https://github.com/melodic-software/claude-code-plugins/issues/5178)). For one exact + approved path, `handoff-apply --execute` re-runs `handoff-verify` in the same process and deletes + on any `clear` verdict: one that passes every evidence gate, or one where `accept_unpublished` with + the operator's reason in `vcs-evidence.json` waives the first two, so a contested throwaway + checkout no longer has to be removed outside the engine. Preview and token apply keep VCS + protection categorical; the acknowledgement is evaluated only in the handoff verification path. + The Bash guard asks for the exact `handoff-apply` shape. Windows and macOS keep `handoff-verify` + and the manual handoff lane. +- **`handoff-apply` is the one lane that removes entries outside the snapshot.** The snapshot + records a repository's `.git` directory without its descendants, so the engine empties that + uninventoried metadata fd-relative before removing it. The purge refuses on a mount point at or + under the metadata directory, on any consumer protection glob match over a live `os.walk`, and on + a directory it cannot read (fail closed); it requires every directory to stay on the metadata + directory's device and unlinks links rather than following them. The hard-protection name check + is not applied to those contents. + +### Changed + +- **clean docs:** `SKILL.md` section 6 carries the `handoff-apply` command block and names the Linux + route in the throwaway-checkout bullet, still telling the operator plainly that unpushed commits + and untracked or ignored files will be lost. `safety-model.md`, `unsupported-platform-handoff.md` + and the README no longer imply the acknowledgement has no Linux route, that the guard asks only + for `apply`, or that the engine removes only snapshot entries. ## [0.29.0] - 2026-09-29 diff --git a/plugins/disk-hygiene/README.md b/plugins/disk-hygiene/README.md index 013b778db8..12fdb34328 100644 --- a/plugins/disk-hygiene/README.md +++ b/plugins/disk-hygiene/README.md @@ -7,9 +7,10 @@ that it is not work product. Safe tidiness is the primary objective; reclaimable secondary signal, so zero-byte and empty-directory residue stay visible in reports. The default lane is read-only. Cleanup is available only through a fresh, exact-path preview followed -by explicit approval of one confidence tier. The engine then rechecks every candidate before removing -only the entries captured in the snapshot; it never follows links or recursively deletes an -unvalidated tree. +by explicit approval of one confidence tier, or, on Linux, through `handoff-apply` for one approved +standalone Git checkout. The engine then rechecks every candidate before removing only the entries +captured in the snapshot, except the Git metadata contents `handoff-apply` empties (see the safety +contract); it never follows links or recursively deletes an unvalidated tree. ## Safety contract @@ -23,25 +24,30 @@ unvalidated tree. via `--root-children` with an explicit child selection), user shell-folder roots, VCS metadata/tracked content, mount points (including Linux bind mounts), every Windows reparse point, symlinks, entries changed since the scan, and paths outside the target are hard stops. These - predicates cannot be disabled by policy. The sole VCS exception is a read-only manual-handoff - evidence mode for an entire standalone Git checkout: it requires empty porcelain status, every - local head SHA confirmed through the checkout's GitHub remote, every stash SHA present in an - independent checkout (or no stashes), and the existing exact-path operator approval. Without all - four, categorical protection remains, except that an `accept_unpublished` acknowledgement on the - evidence entry (one exact approved path, with a reason) waives the first two, the empty status and - the heads confirmed on the remote, and the verdict reports them as accepted-unpublished. The stash - gate and the exact-path operator approval still apply. + predicates cannot be disabled by policy. The sole VCS exception is the evidence mode for an + entire standalone Git checkout: `handoff-verify` reads it without changing anything, and on Linux + `handoff-apply` runs the same checks and then deletes that one approved path. It requires empty + porcelain status, every local head SHA confirmed through the checkout's GitHub remote, every stash + SHA present in an independent checkout (or no stashes), and the existing exact-path operator + approval. Without all four, categorical protection remains, except that an `accept_unpublished` + acknowledgement on the evidence entry (one exact approved path, with a reason) waives the first + two, the empty status and the heads confirmed on the remote, and the verdict reports them as + accepted-unpublished. The stash gate and the exact-path operator approval still apply. - 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. - 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 one canonical apply shape, and denies - every other Bash command. Brace, tilde, parameter, command, arithmetic, process, word-splitting, + 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, filename, redirection, and operator syntax is rejected before argument parsing. - Deletion walks the validated snapshot bottom-up. New entries are not traversed; they make the - directory non-empty and therefore skipped. After captured children are removed, a directory is + directory non-empty and therefore skipped. The one exception is Linux `handoff-apply`, which + empties a verified checkout's `.git` contents, entries the snapshot never inventoried, under the + mount, consumer-glob, readability, device, and link checks in the + [safety model](skills/clean/reference/safety-model.md#standalone-git-checkout-evidence). After + captured children are removed, a directory is reopened with `O_NOFOLLOW`, checked empty through its descriptor, and matched by device, inode, and type immediately before descriptor-relative `rmdir`. The report leads with tidiness outcomes (paths removed, empty directories cleared, and the locked, changed, protected, needs-elevation, @@ -332,10 +338,11 @@ selectable. - Use `/source-control:worktree status`/`cleanup` (if installed) for git worktree checkouts such as a `.worktrees/` tree, run those actions from the checkout's own main repository, as they manage the current repository's worktrees and take no target. `disk-hygiene` protects tracked content and `.git` - metadata but does not manage worktree lifecycle. For a redundant standalone checkout, the manual - handoff's optional VCS evidence mode can return `clear` only after the proof gates in the + metadata but does not manage worktree lifecycle. For a redundant standalone checkout, the optional + VCS evidence mode can return `clear` only after the proof gates in the [safety model](skills/clean/reference/safety-model.md) pass, or an `accept_unpublished` - acknowledgement waives the first two for that one approved path. + acknowledgement waives the first two for that one approved path. On Linux, `handoff-apply` then + deletes that one path in the same call; on Windows and macOS the deletion is the manual handoff. - Use a product's own prune/GC/uninstall command for state it owns. This skill reports the handoff and records the native result but never makes managed state eligible for engine execution. - `git clean` remains the authority for ignored/untracked repository files. This plugin protects every @@ -348,12 +355,12 @@ measurements below carry the conditions they were taken under. - **Code execution:** the plugin runs bundled, standard-library Python. The skill-scoped PreToolUse guard denies every unknown Bash command, permits only canonical bundled scan/preview calls, and - returns a hook-issued `ask` for the canonical engine apply call (same `permissionDecision: "ask"` - as the PowerShell deletion lane; `dontAsk` auto-denies instead of prompting). The guard rejects shell - expansion and operator syntax instead of validating only the post-split argument vector; script - identity follows the host path rules and remains case-sensitive on POSIX. No `eval`, dynamic shell - construction, or downloads are used. Paths cross the process boundary as JSON or individually - quoted CLI arguments. + returns a hook-issued `ask` for the canonical engine `apply` and `handoff-apply` calls (same + `permissionDecision: "ask"` as the PowerShell deletion lane; `dontAsk` auto-denies instead of + prompting). The guard rejects shell expansion and operator syntax instead of validating only the + post-split argument vector; script identity follows the host path rules and remains + case-sensitive on POSIX. No `eval`, dynamic shell construction, or downloads are used. Paths cross + the process boundary as JSON or individually quoted CLI arguments. - **MCP / external trust:** no MCP server, agent, dependency, or third-party service is shipped. - **Configuration:** one non-sensitive `userConfig` boolean (`disk_hygiene_enabled`, default `true`) gating the execution tiers. Setting it `false` puts `/disk-hygiene:clean` in audit-only diff --git a/plugins/disk-hygiene/skills/clean/SKILL.md b/plugins/disk-hygiene/skills/clean/SKILL.md index af507125a1..adcf1cc92e 100644 --- a/plugins/disk-hygiene/skills/clean/SKILL.md +++ b/plugins/disk-hygiene/skills/clean/SKILL.md @@ -413,9 +413,11 @@ Report `reclaimable_local_bytes_removed` and the observed free-space delta **aft figures, never as the headline. Do not claim the observed free-space delta is exact: concurrent disk activity, sparse files, hard links, compression, and delayed allocation affect it. -### Acknowledged throwaway checkout (Linux) +### Standalone checkout deletion (Linux) -For one approved checkout with an [`accept_unpublished` entry](reference/safety-model.md#standalone-git-checkout-evidence), run only: +For one approved standalone checkout, run only the command below. It deletes on any `clear` +verdict: one whose [evidence entry](reference/safety-model.md#standalone-git-checkout-evidence) +carries `accept_unpublished`, or one that passes every evidence gate without it. ```text "" "${CLAUDE_PLUGIN_ROOT}/skills/clean/scripts/hygiene.py" handoff-apply --execute \ @@ -428,17 +430,22 @@ One path per call; any verdict but `clear` removes nothing; confirm the guard's ### Unsupported-platform handoff (Windows, macOS) -Preview reports `execution-platform-unsupported` on Windows and macOS, so the engine never deletes -there and the default outcome is the report. After an execution request and human approval of an -exact single-tier path list, follow [the handoff reference](reference/unsupported-platform-handoff.md); never improvise. +Preview reports `execution-platform-unsupported` as a per-candidate blocker on Windows and macOS, +so the engine never deletes there and the default outcome is the report. When, and only when, +an execution request was made on one of those platforms and the human approved an exact single-tier +path list in this session, read +[reference/unsupported-platform-handoff.md](reference/unsupported-platform-handoff.md) and follow +it. It owns the approved-path forms (inline `--path`, or `handoff-paths.json`), the per-path +revalidation, and the hook belt that outlives the cleanup. Do not improvise a manual deletion +lane from the engine steps above. ## Gotchas Harness mechanics live in one copy, in the safety model, so a fix there cannot leave a stale -restatement behind here. Load [the safety model](reference/safety-model.md) for how the guard -registers on two surfaces, how the kill switch is delivered and scoped, and what the PowerShell lane -flags → "Kill-switch enforcement"; how the hooks launch and what the guard does when no Python -resolves → "Hook launch form". +restatement behind here. Load [the safety model](reference/safety-model.md) when you need +them: how the guard registers on two surfaces, how the kill switch is delivered and scoped, and +what the PowerShell lane flags → "Kill-switch enforcement"; how the hooks launch, what that bounds, +and what the guard does when no Python resolves → "Hook launch form". - POSIX permits unlinking an open file, so successful deletion is not a live-handle check. Linux execution requires an authoritative `lsof` result and fails closed on diagnostics or missing access. @@ -458,15 +465,15 @@ resolves → "Hook launch form". grants none. Consumer permission policy remains authoritative. - The Bash lane is deny-by-default: only the literal-word bundled scan, preview, handoff-verify, apply, and handoff-apply shapes (plus the argument-free kill-switch probe) pass, using the hook - runtime's own absolute interpreter. The same denial text also admits literal-form read-only supporting commands whose - heads are absolute paths under a trusted system directory: `[`, `basename`, `dirname`, `du`, - `file`, `find`, `ls`, `pwd`, `stat`, `test` (`[` only as a complete `/usr/bin/[ ... ]` - expression; `find` without `-delete`/`-exec`/`-ok`/`-fprint`). Bare names are denied because - exported shell functions shadow them. Engine-gate mode answers those supporting commands with - `ask`; belt mode `allow`s them. The denial text is the source if this list and the guard - diverge. Do supporting inspection with non-Bash read-only tools when the command is not in that - set. Shell expansions, globs, splitting/escape forms, operators, redirections, aliases, and - exported functions fail closed. + runtime's own absolute interpreter. The same denial text also admits literal-form read-only + supporting commands whose heads are absolute paths under a trusted system directory: `[`, + `basename`, `dirname`, `du`, `file`, `find`, `ls`, `pwd`, `stat`, `test` (`[` only as a complete + `/usr/bin/[ ... ]` expression; `find` without `-delete`/`-exec`/`-ok`/`-fprint`). Bare names + are denied because exported shell functions shadow them. Engine-gate mode answers those + supporting commands with `ask`; belt mode `allow`s them. The denial text is the source if this + list and the guard diverge. Do supporting inspection with non-Bash read-only tools when the + command is not in that set. Shell expansions, globs, splitting/escape forms, operators, + redirections, aliases, and exported functions fail closed. - The PowerShell lane is the inverse tradeoff: open for read-only support work, hard-denying engine invocations, and turning known deletion spellings into a hook-issued `ask` (`permissionDecision: "ask"`). The hooks reference says that value asks the user about the tool diff --git a/plugins/disk-hygiene/skills/clean/reference/fan-out-worker-brief.md b/plugins/disk-hygiene/skills/clean/reference/fan-out-worker-brief.md index b5ffd25362..67426a3a1b 100644 --- a/plugins/disk-hygiene/skills/clean/reference/fan-out-worker-brief.md +++ b/plugins/disk-hygiene/skills/clean/reference/fan-out-worker-brief.md @@ -20,8 +20,8 @@ redirection, or extra commands in the same tool call. Required flags on every sc Use the hook Python launcher from the skill (`` in `SKILL.md`), not a bare `python3` on PATH. -Do not run `apply`, `preview`, `handoff-verify`, `rm`, `del`, moves, or any command that mutates the -target. Do not wrap the engine in compound shells (`;`, `&&`, `|`). +Do not run `apply`, `preview`, `handoff-verify`, `handoff-apply`, `rm`, `del`, moves, or any +command that mutates the target. Do not wrap the engine in compound shells (`;`, `&&`, `|`). ## Scan invocation templates diff --git a/plugins/disk-hygiene/skills/clean/reference/safety-model.md b/plugins/disk-hygiene/skills/clean/reference/safety-model.md index 7f9877c4d7..4ea235970e 100644 --- a/plugins/disk-hygiene/skills/clean/reference/safety-model.md +++ b/plugins/disk-hygiene/skills/clean/reference/safety-model.md @@ -130,8 +130,9 @@ open file while the process retains the underlying object. Execution is intentionally not cross-platform. Linux requires readable `/proc/self/mountinfo`, `O_NOFOLLOW`, and descriptor-relative stat/unlink/rmdir. Apply anchors the target and every parent to directory descriptors, verifies those descriptor identities, repeats live mount/protection/Git/handle -checks immediately before each operation, and walks only snapshot entries bottom-up. Once captured -children have been removed, apply opens the directory itself with `O_NOFOLLOW`, verifies its stable +checks immediately before each operation, and walks only snapshot entries bottom-up (`handoff-apply` +alone also empties uninventoried Git metadata; see [Standalone Git checkout +evidence](#standalone-git-checkout-evidence)). Once captured children have been removed, apply opens the directory itself with `O_NOFOLLOW`, verifies its stable device/inode/type identity, proves it empty through that descriptor, rechecks the name-to-descriptor identity, and only then calls descriptor-relative `rmdir`. Windows and macOS return `execution-platform-unsupported`; their audit and report behavior is unchanged. @@ -223,8 +224,9 @@ link/reparse, consumer protection, identity/descendant, or live-handle check. `h evidence is read-only. On Windows and macOS, deletion remains a per-path manual handoff under the existing hook-issued `ask`. On Linux, `handoff-apply` consumes the same evidence file for one approved path, runs the `handoff-verify` checks in the same process, and deletes only on `clear`, -under the same `ask`; preview and token apply never evaluate the acknowledgement. The verdict still -expires immediately. +under the same `ask`. It deletes on any `clear` verdict, with or without `accept_unpublished` on +the entry; preview and token apply never evaluate the acknowledgement. The verdict still expires +immediately. | Verdict | Meaning | Manual-lane action | |---|---|---| @@ -241,7 +243,26 @@ any delay or interruption means re-running handoff-verify. Managed-state exclusi always was in the manual lane, with model judgment plus human review of the audit report, because snapshot entries carry no owner claim for the engine to check. -The skill-frontmatter Bash belt accepts only complete literal words in the four declared engine command +`handoff-apply` is the one lane where the engine removes entries outside the snapshot. The snapshot +records a repository's `.git` directory without its descendants, so after the working tree is +removed the engine empties the uninventoried Git metadata contents through descriptor-relative calls +and then removes the directory. These checks bound that purge: + +- A mount point at or under the metadata directory refuses it. +- A consumer protection glob is matched against every path of a live `os.walk` of the metadata + directory; any match refuses it. +- A directory the walk cannot read fails closed as `needs-elevation` or + `filesystem-state-unverified`. +- Each directory is opened with `O_NOFOLLOW` and its `st_dev` must equal the metadata directory's, + so the purge never crosses a device. +- A link inside is unlinked as a link and never followed. + +The mount, glob, and readability checks run once before anything is removed, where a refusal removes +nothing, and again immediately before the metadata directory is emptied, where a refusal leaves that +directory in place after the working tree is already gone. The hard-protection name check is not +applied to the contents. + +The skill-frontmatter Bash belt accepts only complete literal words in the declared engine command shapes. It rejects every Bash expansion family, glob/word-splitting input, redirection, operator, escape, and compound-command form before validating arguments. Canonical script-path comparison uses the host platform's path case rules; POSIX path identity is never case-folded. A `--data-root` value @@ -468,7 +489,7 @@ the same session (a later `Remove-Item` is still prompted long after cleanup end retracted by finishing the cleanup. Only the session's end clears it. An absent, unreadable, or ambiguous read fails **closed to enabled**: the guard stays active and forces a human prompt before every mutation **it sees**, meaning every Bash engine `apply` -and, on PowerShell, only the flagged spellings above, so an unreadable toggle never silently disables +and `handoff-apply` and, on PowerShell, only the flagged spellings above, so an unreadable toggle never silently disables the guard. **The gate's "different file" escape stops at this plugin's own cache tree.** A word naming an existing diff --git a/plugins/disk-hygiene/skills/clean/scripts/hygiene.py b/plugins/disk-hygiene/skills/clean/scripts/hygiene.py index 584e4abc5e..f982c3352b 100755 --- a/plugins/disk-hygiene/skills/clean/scripts/hygiene.py +++ b/plugins/disk-hygiene/skills/clean/scripts/hygiene.py @@ -4077,8 +4077,9 @@ def opaque_contents_blocker( ) -> str | None: """The reason a directory's uninventoried contents may not be purged, or None. - The snapshot names nothing beneath Git metadata, so the protections - apply and handoff-verify check per entry are checked here per live path. + The snapshot names nothing beneath Git metadata, so this checks the live + contents for mount points, consumer protection globs and unreadable + directories. Hard-protection names are not checked here. """ if any(is_within(mount, path) for mount in mounts): return "nested-mount-point" diff --git a/plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py b/plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py index c5e7a4e38e..d2f1a9a400 100755 --- a/plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py +++ b/plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py @@ -7108,6 +7108,22 @@ def test_unacknowledged_checkout_stays_contested_and_nothing_is_removed( self.assertIn("vcs-evidence-status-not-clean", detail) self.assertIn("vcs-evidence-remote-not-declared", detail) + def test_a_fully_verified_checkout_is_deleted_without_an_acknowledgement( + self, + ) -> None: + checkout = HandoffVerifyTests.create_checkout(self.target) + + def confirm(_repo: Path, remote: str, sha: str): + return True, {"sha": sha, "remote": remote, "repository": "example/project"} + + with mock.patch.object( + hygiene, "verify_github_remote_head", side_effect=confirm + ): + report = self.apply(self.snapshot(), HandoffVerifyTests.vcs_configuration()) + self.assertEqual("completed", report["status"], report["skipped"]) + self.assertFalse(checkout.exists()) + self.assertEqual([], report["accept_unpublished"]) + def test_an_acknowledgement_for_another_path_authorizes_nothing(self) -> None: acked = self.checkout("acked") plain = self.checkout("plain") From 519eab631a66c48c1c0d6615ece114aa32150550 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:34:53 -0400 Subject: [PATCH 6/8] fix(disk-hygiene): recheck consumer protection globs while purging Git metadata The pre-purge scan cannot see an entry created after it ran. The purge now matches every child against the consumer protection globs as it reaches it and refuses the removal on a match. Refs #5178 Co-Authored-By: Claude Opus 5.5 --- plugins/disk-hygiene/CHANGELOG.md | 3 +- .../skills/clean/reference/safety-model.md | 5 ++- .../skills/clean/scripts/hygiene.py | 40 +++++++++++++++---- .../skills/clean/scripts/test_hygiene.py | 20 ++++++++++ 4 files changed, 57 insertions(+), 11 deletions(-) diff --git a/plugins/disk-hygiene/CHANGELOG.md b/plugins/disk-hygiene/CHANGELOG.md index 00fcd676b0..8f2cf829fe 100644 --- a/plugins/disk-hygiene/CHANGELOG.md +++ b/plugins/disk-hygiene/CHANGELOG.md @@ -21,7 +21,8 @@ All notable changes to the `disk-hygiene` plugin are documented here. Format fol uninventoried metadata fd-relative before removing it. The purge refuses on a mount point at or under the metadata directory, on any consumer protection glob match over a live `os.walk`, and on a directory it cannot read (fail closed); it requires every directory to stay on the metadata - directory's device and unlinks links rather than following them. The hard-protection name check + directory's device and unlinks links rather than following them. Each child is matched against + the consumer protection globs again as the purge reaches it. The hard-protection name check is not applied to those contents. ### Changed diff --git a/plugins/disk-hygiene/skills/clean/reference/safety-model.md b/plugins/disk-hygiene/skills/clean/reference/safety-model.md index 4ea235970e..249ef6a9ac 100644 --- a/plugins/disk-hygiene/skills/clean/reference/safety-model.md +++ b/plugins/disk-hygiene/skills/clean/reference/safety-model.md @@ -259,8 +259,9 @@ and then removes the directory. These checks bound that purge: The mount, glob, and readability checks run once before anything is removed, where a refusal removes nothing, and again immediately before the metadata directory is emptied, where a refusal leaves that -directory in place after the working tree is already gone. The hard-protection name check is not -applied to the contents. +directory in place after the working tree is already gone. The purge also matches each child against +the consumer protection globs as it reaches it, so an entry created after those checks is refused, +not deleted. The hard-protection name check is not applied to the contents. The skill-frontmatter Bash belt accepts only complete literal words in the declared engine command shapes. It rejects every Bash expansion family, glob/word-splitting input, redirection, operator, diff --git a/plugins/disk-hygiene/skills/clean/scripts/hygiene.py b/plugins/disk-hygiene/skills/clean/scripts/hygiene.py index f982c3352b..034244ebfe 100755 --- a/plugins/disk-hygiene/skills/clean/scripts/hygiene.py +++ b/plugins/disk-hygiene/skills/clean/scripts/hygiene.py @@ -19,7 +19,7 @@ import subprocess import sys import tempfile -from collections.abc import Iterable +from collections.abc import Callable, Iterable from pathlib import Path, PurePosixPath from typing import Any @@ -4043,12 +4043,19 @@ def open_anchored_parent( MAX_PURGE_DEPTH = 64 -def purge_directory_contents(directory_fd: int, device: int, depth: int = 0) -> None: +def purge_directory_contents( + directory_fd: int, + device: int, + directory: Path, + protected: Callable[[Path], bool], + depth: int = 0, +) -> None: """Empty an open directory fd-relative: no link is followed, no device crossed. For a directory the snapshot recorded without descendants (Git metadata), where ``anchored_remove`` has no inventory to walk. A symlink is unlinked as - a link, never entered. + a link, never entered. Every child is checked with ``protected`` when it is + reached, so an entry created after the pre-purge scan is refused, not deleted. """ if depth > MAX_PURGE_DEPTH: raise HygieneError("directory contents nest too deeply to purge") @@ -4057,6 +4064,8 @@ def purge_directory_contents(directory_fd: int, device: int, depth: int = 0) -> (child.name, child.is_dir(follow_symlinks=False)) for child in iterator ] for name, is_directory in children: + if protected(directory / name): + raise HygieneError("directory contents gained a protected path") if not is_directory: os.unlink(name, dir_fd=directory_fd) continue @@ -4066,7 +4075,9 @@ def purge_directory_contents(directory_fd: int, device: int, depth: int = 0) -> try: if os.fstat(child_fd).st_dev != device: raise HygieneError("directory contents cross a device boundary") - purge_directory_contents(child_fd, device, depth + 1) + purge_directory_contents( + child_fd, device, directory / name, protected, depth + 1 + ) finally: os.close(child_fd) os.rmdir(name, dir_fd=directory_fd) @@ -4106,7 +4117,7 @@ def anchored_remove( entries: dict[str, dict[str, Any]], target: Path, *, - purge_contents: bool = False, + purge_protected: Callable[[Path], bool] | None = None, ) -> None: parent_fd, name = open_anchored_parent(target_fd, relative, entries) try: @@ -4131,8 +4142,13 @@ def anchored_remove( current, opened ): raise HygieneError("anchored directory changed since the snapshot") - if purge_contents: - purge_directory_contents(directory_fd, opened.st_dev) + if purge_protected is not None: + purge_directory_contents( + directory_fd, + opened.st_dev, + target.joinpath(*PurePosixPath(relative).parts), + purge_protected, + ) with os.scandir(directory_fd) as iterator: if next(iterator, None) is not None: raise HygieneError("anchored directory is not empty") @@ -4441,6 +4457,9 @@ def handoff_apply( ) globs = snapshot_protection_globs(snapshot) + def consumer_protected(path: Path) -> bool: + return bool(consumer_protection_matches(path, target, globs)) + def path_blockers(path: Path, mounts: set[Path]) -> list[str]: reasons = evidence_adjusted_protections( hard_protection(path, target, exact_names, mounts), @@ -4545,7 +4564,12 @@ def path_blockers(path: Path, mounts: set[Path]) -> list[str]: continue try: anchored_remove( - target_fd, name, entry, entries, target, purge_contents=purge + target_fd, + name, + entry, + entries, + target, + purge_protected=consumer_protected if purge else None, ) except HygieneError as exc: skipped.append( diff --git a/plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py b/plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py index d2f1a9a400..34499c21ab 100755 --- a/plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py +++ b/plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py @@ -7321,6 +7321,26 @@ def test_a_link_inside_git_metadata_is_unlinked_not_followed(self) -> None: self.assertFalse(checkout.exists()) self.assertEqual("precious\n", (outside / "precious.txt").read_text("utf-8")) + def test_a_protected_path_created_inside_git_metadata_after_the_scan_is_kept( + self, + ) -> None: + checkout = self.checkout() + snapshot = self.snapshot() + snapshot["policy"]["additional_protected_path_globs"] = ["checkout/.git/late/*"] + late = checkout / ".git" / "late" + + def create_after_the_scan(*_args: Any) -> None: + late.mkdir(exist_ok=True) + (late / "kept.txt").write_text("kept\n", encoding="utf-8") + + with mock.patch.object( + hygiene, "opaque_contents_blocker", side_effect=create_after_the_scan + ): + report = self.apply(snapshot, self.evidence()) + self.assertEqual("completed-with-skips", report["status"], report) + self.assertEqual("kept\n", (late / "kept.txt").read_text("utf-8")) + self.assertTrue(checkout.is_dir()) + def cli(self, snapshot, evidence, *extra: str) -> tuple[int, dict[str, Any]]: (self.base / "snapshot.json").write_text(json.dumps(snapshot), "utf-8") (self.base / "evidence.json").write_text( From c36b04def58a98dc732cb79b8d6b5da33dbfa17a Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 30 Sep 2026 02:43:17 -0400 Subject: [PATCH 7/8] docs(disk-hygiene): move the handoff-apply command block to the safety model SKILL.md sat at the 500-line hard cap after the merge with main. The Linux command block moves next to the standalone checkout evidence it consumes, and the skill bullet links to it. Refs #5178 Co-Authored-By: Claude Opus 5.5 --- plugins/disk-hygiene/CHANGELOG.md | 2 +- plugins/disk-hygiene/skills/clean/SKILL.md | 26 ++++--------------- .../skills/clean/reference/safety-model.md | 10 +++++++ .../reference/unsupported-platform-handoff.md | 2 +- .../skills/clean/scripts/test_hygiene.py | 8 ++++-- 5 files changed, 23 insertions(+), 25 deletions(-) diff --git a/plugins/disk-hygiene/CHANGELOG.md b/plugins/disk-hygiene/CHANGELOG.md index af42e404a6..3ff3aaeff4 100644 --- a/plugins/disk-hygiene/CHANGELOG.md +++ b/plugins/disk-hygiene/CHANGELOG.md @@ -27,7 +27,7 @@ All notable changes to the `disk-hygiene` plugin are documented here. Format fol ### Changed -- **clean docs:** `SKILL.md` section 6 carries the `handoff-apply` command block and names the Linux +- **clean docs:** `safety-model.md` carries the `handoff-apply` command block and `SKILL.md` names the Linux route in the throwaway-checkout bullet, still telling the operator plainly that unpushed commits and untracked or ignored files will be lost. `safety-model.md`, `unsupported-platform-handoff.md` and the README no longer imply the acknowledgement has no Linux route, that the guard asks only diff --git a/plugins/disk-hygiene/skills/clean/SKILL.md b/plugins/disk-hygiene/skills/clean/SKILL.md index f826ac1d35..48c0b7ff33 100644 --- a/plugins/disk-hygiene/skills/clean/SKILL.md +++ b/plugins/disk-hygiene/skills/clean/SKILL.md @@ -86,12 +86,11 @@ blocked target, 3 when elevation is needed or filesystem state could not be veri approved checkout. When the operator wants a `contested` throwaway checkout gone anyway (no remote, untracked files, no commits), never delete it without a clear `handoff-verify` verdict. Record `accept_unpublished` with the operator's reason for that exact approved path in - `vcs-evidence.json`; every other contest reason must be gone. On Linux, run the engine route in - §6 (`handoff-apply`): it re-runs `handoff-verify` and deletes only on a `clear` verdict. On - Windows and macOS, run `handoff-verify`, and delete only on a `clear` verdict through the §6 - manual handoff lane. Preview and token apply keep VCS protection categorical; the - acknowledgement exists only in `handoff-verify`. Before deleting, tell the operator plainly that - unpushed commits and untracked or ignored files in that checkout will be lost. + `vcs-evidence.json`; every other contest reason must be gone. On Linux, run `handoff-apply` + ([command](reference/safety-model.md#standalone-git-checkout-evidence)): it re-runs + `handoff-verify` and deletes only on a `clear` verdict. On Windows and macOS, run `handoff-verify`, + and delete only on a `clear` verdict through the §6 manual handoff lane. Before deleting, tell the + 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. @@ -434,21 +433,6 @@ Report `reclaimable_local_bytes_removed` and the observed free-space delta **aft figures, never as the headline. Do not claim the observed free-space delta is exact: concurrent disk activity, sparse files, hard links, compression, and delayed allocation affect it. -### Standalone checkout deletion (Linux) - -For one approved standalone checkout, run only the command below. It deletes on any `clear` -verdict: one whose [evidence entry](reference/safety-model.md#standalone-git-checkout-evidence) -carries `accept_unpublished`, or one that passes every evidence gate without it. - -```text -"" "${CLAUDE_PLUGIN_ROOT}/skills/clean/scripts/hygiene.py" handoff-apply --execute \ - --snapshot "/snapshot.json" --path "relative/checkout" \ - --vcs-evidence "/vcs-evidence.json" --report "/report-handoff.json" \ - --data-root "${CLAUDE_PLUGIN_DATA}" -``` - -One path per call; any verdict but `clear` removes nothing; confirm the guard's `ask` only for that path. - ### Unsupported-platform handoff (Windows, macOS) Preview reports `execution-platform-unsupported` as a per-candidate blocker on Windows and macOS, diff --git a/plugins/disk-hygiene/skills/clean/reference/safety-model.md b/plugins/disk-hygiene/skills/clean/reference/safety-model.md index 6447c8d550..130a2216f6 100644 --- a/plugins/disk-hygiene/skills/clean/reference/safety-model.md +++ b/plugins/disk-hygiene/skills/clean/reference/safety-model.md @@ -232,6 +232,16 @@ under the same `ask`. It deletes on any `clear` verdict, with or without `accept the entry; preview and token apply never evaluate the acknowledgement. The verdict still expires immediately. +The Linux command, one approved standalone checkout per call. Any verdict but `clear` removes +nothing; confirm the guard's `ask` only for that path. + +```text +"" "${CLAUDE_PLUGIN_ROOT}/skills/clean/scripts/hygiene.py" handoff-apply --execute \ + --snapshot "/snapshot.json" --path "relative/checkout" \ + --vcs-evidence "/vcs-evidence.json" --report "/report-handoff.json" \ + --data-root "${CLAUDE_PLUGIN_DATA}" +``` + | Verdict | Meaning | Manual-lane action | |---|---|---| | `clear` | Every check passed against live state at emission time | Delete this exact path immediately. Verify one path per deletion, never one batch for all (earlier checks age while later paths are probed) | diff --git a/plugins/disk-hygiene/skills/clean/reference/unsupported-platform-handoff.md b/plugins/disk-hygiene/skills/clean/reference/unsupported-platform-handoff.md index 35cfa362ba..dec4ecfcff 100644 --- a/plugins/disk-hygiene/skills/clean/reference/unsupported-platform-handoff.md +++ b/plugins/disk-hygiene/skills/clean/reference/unsupported-platform-handoff.md @@ -109,7 +109,7 @@ engine plan: exact approved path, and tell the operator that unpushed commits and untracked or ignored files in it will be lost. The acknowledgement relaxes only those two gates; see [the safety model](safety-model.md#standalone-git-checkout-evidence). This lane is for Windows - and macOS; on Linux the engine's `handoff-apply` (SKILL.md section 6) reads the same evidence + and macOS; on Linux the engine's `handoff-apply` (command in `safety-model.md`) reads the same evidence file and does the verify and the deletion in one process. Then run: ```text diff --git a/plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py b/plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py index 8c03d4ca66..c134464bca 100755 --- a/plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py +++ b/plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py @@ -6529,13 +6529,17 @@ def test_skill_requires_handoff_verify_and_loss_warning(self) -> None: for phrase in ( "never delete it without a clear `handoff-verify` verdict", "run `handoff-verify`, and delete only on a `clear` verdict", - "On Linux, run the engine route in §6 (`handoff-apply`): it re-runs " + "On Linux, run `handoff-apply` ([command](reference/safety-model.md" + "#standalone-git-checkout-evidence)): it re-runs " "`handoff-verify` and deletes only on a `clear` verdict", - "handoff-apply --execute", "tell the operator plainly that unpushed commits and untracked or " "ignored files in that checkout will be lost", ): self.assertIn(phrase, text) + safety_model = (SCRIPT_DIR.parent / "reference" / "safety-model.md").read_text( + "utf-8" + ) + self.assertIn("handoff-apply --execute", safety_model) def verify_evidence(self, target: Path, approved: list[str], evidence): snapshot = hygiene.scan_tree(target.resolve(), hygiene.load_policy(None)) From 3ddc0352fd04744f85fa59a6adab46cbfc2f89ba Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:43:27 -0400 Subject: [PATCH 8/8] fix(disk-hygiene): name the handoff-apply script through the skill directory The spoke plugin-root check allows seven plugin-root tokens in safety-model.md; the handoff-apply block made eight. Co-Authored-By: Claude Opus 5.5 --- plugins/disk-hygiene/skills/clean/reference/safety-model.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/disk-hygiene/skills/clean/reference/safety-model.md b/plugins/disk-hygiene/skills/clean/reference/safety-model.md index 4a5402f7db..5128ea0800 100644 --- a/plugins/disk-hygiene/skills/clean/reference/safety-model.md +++ b/plugins/disk-hygiene/skills/clean/reference/safety-model.md @@ -270,7 +270,7 @@ The Linux command, one approved standalone checkout per call. Any verdict but `c nothing; confirm the guard's `ask` only for that path. ```text -"" "${CLAUDE_PLUGIN_ROOT}/skills/clean/scripts/hygiene.py" handoff-apply --execute \ +"" "/scripts/hygiene.py" handoff-apply --execute \ --snapshot "/snapshot.json" --path "relative/checkout" \ --vcs-evidence "/vcs-evidence.json" --report "/report-handoff.json" \ --data-root "${CLAUDE_PLUGIN_DATA}"