diff --git a/plugins/disk-hygiene/.claude-plugin/plugin.json b/plugins/disk-hygiene/.claude-plugin/plugin.json index 4f7b4158f8..74578dcdda 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.42.0", + "version": "0.42.1", "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 1daed17d7a..dde79d5a54 100644 --- a/plugins/disk-hygiene/CHANGELOG.md +++ b/plugins/disk-hygiene/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to the `disk-hygiene` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.42.1] - 2026-09-30 + +### Fixed + +- **The engine gate accepts braces inside a quoted word** ([#5641](https://github.com/melodic-software/claude-code-plugins/issues/5641)). A `--target` such as `'D:/wsl/{673ac4db-a2e3-459e-882c-1ec71b253aa2}'` now forms an exact engine call, so the WSL distro folders can be snapshotted. `{` and `}` are literal inside a whole-word single or double quote and are accepted only there; every other expansion or operator character, `$` included, is still refused wherever it sits, and an unquoted brace is still refused. The denial no longer names a quoted brace as the culprit. + ## [0.42.0] - 2026-09-30 ### Added diff --git a/plugins/disk-hygiene/skills/clean/scripts/destructive_guard.py b/plugins/disk-hygiene/skills/clean/scripts/destructive_guard.py index 87349ea350..361d213a9a 100755 --- a/plugins/disk-hygiene/skills/clean/scripts/destructive_guard.py +++ b/plugins/disk-hygiene/skills/clean/scripts/destructive_guard.py @@ -67,6 +67,15 @@ _SHELL_EXPANSION_OR_OPERATOR_CHARS = frozenset("{}$*?[]~`()<>;|&\r\n\t!#") +# A whole-word quoted span: a quote at a word start, no quote inside, the same +# quote closing it, then a space or the end. Braces are inert inside one. +_WHOLE_WORD_QUOTED = re.compile(r"(?['\"])[^'\"]*(?P=q)(?= |$)") +_BRACES = str.maketrans("", "", "{}") + + +def _without_quoted_braces(command: str) -> str: + """``command`` with ``{`` and ``}`` dropped from inside whole-word quotes.""" + return _WHOLE_WORD_QUOTED.sub(lambda m: m.group().translate(_BRACES), command) def decision(value: str, reason: str) -> dict[str, object]: @@ -181,10 +190,13 @@ def _literal_shell_words( ``allow_backslash`` permits ``\\`` inside words for surfaces where it is a path separator rather than an escape character (PowerShell commands); the - Bash default keeps rejecting it. + Bash default keeps rejecting it. ``{`` and ``}`` are accepted only inside a + whole-word quote, where they are literal; every other expansion or operator + character is rejected wherever it sits. """ if not command or any( - value in _SHELL_EXPANSION_OR_OPERATOR_CHARS for value in command + value in _SHELL_EXPANSION_OR_OPERATOR_CHARS + for value in _without_quoted_braces(command) ): return None words: list[str] = [] @@ -1433,7 +1445,9 @@ def _parse_bracket_test_words(command: str) -> list[str] | None: if len(text) < 2 or text[0] != "[" or text[-1] != "]": return None interior = text[1:-1] - if any(value in _SHELL_EXPANSION_OR_OPERATOR_CHARS for value in interior): + if _SHELL_EXPANSION_OR_OPERATOR_CHARS.intersection( + _without_quoted_braces(interior) + ): return None stripped = interior.strip() if not stripped: @@ -1661,7 +1675,9 @@ def _absolute_bracket_test_words(command: str) -> tuple[str, list[str]] | None: if rest != "]" and not rest.endswith(" ]"): return None interior = "" if rest == "]" else rest[:-1].strip() - if any(value in _SHELL_EXPANSION_OR_OPERATOR_CHARS for value in interior): + if _SHELL_EXPANSION_OR_OPERATOR_CHARS.intersection( + _without_quoted_braces(interior) + ): return None if not interior: return head, [] @@ -2178,7 +2194,7 @@ def _bash_allowlist_disclosure(authority: str | None) -> str: def _unparsable_reason(command: str) -> str: """Name the first thing in ``command`` that ``_literal_shell_words`` rejects.""" - for char in command: + for char in _without_quoted_braces(command): if char in _OPERATOR_LABELS: culprit = f"{_OPERATOR_LABELS[char]} ({char!r})" break diff --git a/plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py b/plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py index e58d616247..389046f09d 100755 --- a/plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py +++ b/plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py @@ -10564,6 +10564,58 @@ def test_engine_mismatch_reason_names_only_the_operator_present(self) -> None: if other != label: self.assertNotIn(phrase, reason) + def test_unparsable_reason_blames_an_unquoted_brace_never_a_quoted_one( + self, + ) -> None: + expansion = "a substitution or expansion" + for command, blamed in ( + ("a {b,c}", "'{'"), + ("a '{b}' {c}", "'{'"), + ("a '{b}' $(x)", "'$'"), + ('a "{b}" | c', "a pipe"), + ): + with self.subTest(command=command): + self.assertIn(blamed, guard._unparsable_reason(command)) + self.assertIn(expansion, guard._unparsable_reason("a {b,c}")) + for command in ("a '{b}' c\\d", "a '{b}' x'y'", 'a "{b}" \t'): + with self.subTest(command=command): + reason = guard._unparsable_reason(command) + self.assertNotIn("'{'", reason) + self.assertNotIn("'}'", reason) + self.assertNotIn(expansion, reason) + + def test_literal_parser_accepts_braces_only_inside_whole_word_quotes( + self, + ) -> None: + parse = guard._literal_shell_words + self.assertEqual(["a", "{b}"], parse("a '{b}'")) + self.assertEqual(["a", "x{y} z"], parse('a "x{y} z"')) + self.assertEqual(["a", "{b}", "c"], parse("a '{b}' c", allow_backslash=True)) + self.assertEqual(["a", "C:\\{g}"], parse('a "C:\\{g}"', allow_backslash=True)) + for command in ( + "a {b}", + "a x{b}", + "a '{b}'x", + "a x'{b}'", + "a '{b}' {c}", + "a '{b}", + 'a "${x}"', + 'a "$(x)"', + 'a "`x`"', + "a '$x{b}'", + "a '{b}' $x", + ): + with self.subTest(command=command): + self.assertIsNone(parse(command)) + + def test_quoted_braces_keep_engine_relevance_on_both_surfaces(self) -> None: + script = guard._display_path(guard._engine_script_path()) + command = f"python3 \"{script}\" scan --target '{{g}}' --output s" + self.assertTrue(guard._engine_gate_relevant(command, "Bash")) + self.assertTrue(guard._engine_gate_relevant(command, "PowerShell")) + self.assertFalse(guard._engine_gate_relevant("ls '{g}'", "Bash")) + self.assertFalse(guard._engine_gate_relevant("Get-Item '{g}'", "PowerShell")) + def test_operator_labels_cover_the_characters_the_literal_parser_rejects( self, ) -> None: @@ -11124,6 +11176,13 @@ def test_bracket_test_is_not_trusted_on_name_alone(self) -> None: self.assertFalse( guard.is_exact_readonly_supporting_command("/usr/bin/[ -d $(pwd) ]") ) + guid = "D:/wsl/{673ac4db-a2e3-459e-882c-1ec71b253aa2}" + self.assertTrue( + guard.is_exact_readonly_supporting_command(f"/usr/bin/[ -d '{guid}' ]") + ) + self.assertFalse( + guard.is_exact_readonly_supporting_command(f"/usr/bin/[ -d {guid} ]") + ) def test_classifier_rejects_a_subcommand_outside_the_shared_list(self) -> None: """The denial text and the grammar are one list, so they cannot drift.""" @@ -15556,6 +15615,38 @@ def test_every_value_flag_carries_a_literal_it_admits(self) -> None: self.grammar.literal_value_ok(flag, cast(str, flag.example)) ) + def test_a_quoted_brace_target_is_classified_like_a_plain_one(self) -> None: + guid = "D:/wsl/{673ac4db-a2e3-459e-882c-1ec71b253aa2}" + for name in ("scan", "inventory"): + spec = self.grammar.subcommand(name) + plain = self.words(spec, optionals=False) + plain[plain.index("--target") + 1] = "target-dir" + braced = [guid if word == "target-dir" else word for word in plain] + command = self.command(name, braced) + single, double = command, command.replace(f"'{guid}'", f'"{guid}"') + self.assertIn(f"'{guid}'", single) + self.assertIn(f'"{guid}"', double) + expected = self.classify(name, plain) + self.assertEqual(name, expected) + for label, text in (("single", single), ("double", double)): + with self.subTest(subcommand=name, quote=label): + self.assertEqual( + expected, + guard.classify_exact_engine_command(text, self.AUTHORITY), + ) + + def test_an_unquoted_or_expanding_brace_target_is_refused(self) -> None: + spec = self.grammar.subcommand("scan") + words = self.words(spec, optionals=False) + head = self.command("scan", words) + for target in ("{a,b}", "x{a}", "'{a}'x", '"${x}"', '"$(x)"', "'{a}' '{b'"): + with self.subTest(target=target): + self.assertIsNone( + guard.classify_exact_engine_command( + f"{head} --policy {target}", self.AUTHORITY + ) + ) + def test_parser_declares_exactly_the_grammar_flags(self) -> None: subparsers = self.subparsers() self.assertEqual(list(self.grammar.SUBCOMMAND_NAMES), list(subparsers))