From 1f4ca35d08eabd901452216e37ce8d1328563b57 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sat, 15 Aug 2026 05:10:43 +0000 Subject: [PATCH] fix(disk-hygiene): flag PowerShell >> append redirection like > The single-`>` redirect pattern rejected both characters of a `>>` pair, so append-to-file commands ran without a prompt. Match append explicitly (`>>` / `2>>` / `*>>`), keep `>> $null` silent with a real token terminator after `$null`, and cover punctuation continuations with a regression test. Version 0.20.5 sits above main tip 0.20.4 from #2671. Closes #2675 Co-authored-by: Kyle Sexton --- .../disk-hygiene/.claude-plugin/plugin.json | 2 +- plugins/disk-hygiene/CHANGELOG.md | 13 +++++++++ .../skills/clean/scripts/destructive_guard.py | 17 +++++++++++- .../skills/clean/scripts/test_hygiene.py | 27 +++++++++++++++++++ 4 files changed, 57 insertions(+), 2 deletions(-) diff --git a/plugins/disk-hygiene/.claude-plugin/plugin.json b/plugins/disk-hygiene/.claude-plugin/plugin.json index 53b8ded26..431a9fdc9 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.20.4", + "version": "0.20.5", "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 9756230f7..e08483f1f 100644 --- a/plugins/disk-hygiene/CHANGELOG.md +++ b/plugins/disk-hygiene/CHANGELOG.md @@ -3,6 +3,19 @@ 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.20.5] + +### Fixed + +- **PowerShell `>>` append redirection is flagged like `>` (#2675).** `_POWERSHELL_OUTPUT_REDIRECT` + matched neither character of a `>>` pair — `(?![=>&])` rejects the first `>`, and the lookbehind + rejects the second — so ` >> append.txt` wrote a file with no prompt while the same command + with `>` prompted. Append is matched explicitly (`>>`, `2>>`, `*>>`) without widening that + lookahead (load-bearing for the stream-merge exclusion from #2627 and the `$null`-discard + exclusion from #2671). `>> $null` stays silent — a discard, not a file write — and requires a + real token terminator after `$null` so punctuation continuations like `>>$null/out.txt` stay + flagged. + ## [0.20.4] ### Fixed diff --git a/plugins/disk-hygiene/skills/clean/scripts/destructive_guard.py b/plugins/disk-hygiene/skills/clean/scripts/destructive_guard.py index aea2f4f87..d7b55bb47 100755 --- a/plugins/disk-hygiene/skills/clean/scripts/destructive_guard.py +++ b/plugins/disk-hygiene/skills/clean/scripts/destructive_guard.py @@ -1014,9 +1014,22 @@ def is_exact_kill_switch_probe(command: str) -> bool: # File redirects still match: `2>out.txt`, `> out.txt`, `1>file`, and a command # that discards one stream while redirecting another (`... 2>$null > out.txt`), # whose second `>` has no `$null` after it. +# Append (`>>`) is NOT covered here: `(?![=>&])` rejects the first `>` of the +# pair and the lookbehind rejects the second, so `>>` is invisible — see +# `_POWERSHELL_APPEND_REDIRECT` (#2675). _POWERSHELL_OUTPUT_REDIRECT = re.compile( r"(?i)(?])>(?![=>&])(?![^\S\n]*\$null(?=[\s;|)}]|$))" ) +# File append (`>>`, `2>>file`, `*>>file`). Matched separately rather than by +# widening the single-`>` lookahead, which is load-bearing for the stream-merge +# and `$null`-discard exclusions above. Exclude `>> $null` the same way +# guardrails' `ps::write_bypass` excludes the `$null` discard — case- +# insensitively, horizontal whitespace only, and with a real token terminator +# after `$null` (whitespace, `;`, `|`, `)`, `}`, or end-of-string) so +# punctuation continuations like `>>$null/out.txt` stay flagged. +_POWERSHELL_APPEND_REDIRECT = re.compile( + r"(?i)(?])>>(?![^\S\n]*\$null(?=[\s;|)}]|$))" +) _POWERSHELL_DOTNET_DELETE = re.compile(r"(?i)(::\s*delete|\.\s*delete\s*\()") # robocopy is an executable normally invocable by full path # (C:\Windows\System32\robocopy.exe), so unlike the cmdlet word list its @@ -1072,7 +1085,9 @@ def powershell_decision(command: str, enabled: bool) -> tuple[str, str] | None: enabled, "disk-hygiene flagged New-Item -Force (truncates an existing file).", ) - if _POWERSHELL_OUTPUT_REDIRECT.search(command): + if _POWERSHELL_OUTPUT_REDIRECT.search( + command + ) or _POWERSHELL_APPEND_REDIRECT.search(command): return _powershell_mutation_verdict( enabled, "disk-hygiene flagged shell output redirection (may overwrite a file).", diff --git a/plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py b/plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py index d4e635a2e..97583c7a8 100755 --- a/plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py +++ b/plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py @@ -5604,6 +5604,33 @@ def test_powershell_null_discards_are_not_file_redirects(self) -> None: ): self.assertIsNone(self.run_guard_powershell(command), command) + def test_powershell_append_redirects_are_file_writes(self) -> None: + """#2675: `>>` appends to a file; it must prompt like `>`, not slip past.""" + for command in ( + "Get-ChildItem C:/tmp >> append.txt", + "Get-ChildItem C:/tmp 2>>err.txt", + "Get-ChildItem C:/tmp *>>all.txt", + # `$nullish` is an ordinary variable target, not the null device. + "Get-ChildItem C:/tmp >>$nullish", + # Punctuation after `$null` is a path continuation, not a discard. + "Get-ChildItem C:/tmp >>$null/out.txt", + "Get-ChildItem C:/tmp 2>>$null\\evil.ps1", + ): + result = self.run_guard_powershell(command) + assert result is not None, command + self.assertEqual( + "ask", + result["hookSpecificOutput"]["permissionDecision"], + command, + ) + for command in ( + "Get-ChildItem C:/tmp >> $null", + "Get-ChildItem C:/tmp >>$null", + "Get-ChildItem C:/tmp 2>>$null", + "Get-ChildItem C:/tmp *>>$NULL", + ): + self.assertIsNone(self.run_guard_powershell(command), command) + def test_powershell_bare_name_mentions_defer_but_engine_identity_denies( self, ) -> None: