Severity carried: High (disk-hygiene-hooks F2) · Medium (F4) — uncalibrated
Problem
Issue 2595 was closed by PR 2639 ("session-honest belt, read-only allowlist, Recycle Bin detection"),
with the guard allowlist re-landed by PR 2706 after a silent revert. Shell.Application Recycle Bin
detection did ship: plugins/disk-hygiene/skills/clean/scripts/destructive_guard.py now carries
dedicated NameSpace / MoveHere / InvokeVerb patterns.
The shipped gate requires the literal Recycle Bin folder id — NameSpace(10) or NameSpace(0xa)
— to appear somewhere in the command. That covers the spellings written against the bin: purging an
item already in it, and the NameSpace(10).MoveHere($path) push form.
It does not cover the shape where NameSpace() takes the containing folder's path and the delete
verb is invoked on the item — $sh.NameSpace('<folder path>').ParseName('victim').InvokeVerb('delete').
That is the ordinary way to send a specific item to the Recycle Bin, and it still returns no verdict.
Evidence
Executed against origin/main at 534eac1382a1e18212295210e18fbbf5a3b8f4dc on 2026-08-16, driving
powershell_decision(command, enabled=True) from main's destructive_guard.py (blob OID verified
with git rev-parse origin/main:<path>). None means the guard defers — no verdict, no prompt.
1 SEND to bin (folder path) -> DEFER (no verdict, None)
command: $sh = New-Object -ComObject Shell.Application; $item = $sh.NameSpace('C:\some\parent').ParseName('victim'); $item.InvokeVerb('delete')
2 SEND + unrelated NameSpace(10) -> 'ask' :: disk-hygiene flagged a Shell.Application NameSpace(10) Recycle Bin MoveHere/InvokeVerb del
command: $sh = New-Object -ComObject Shell.Application; $null = $sh.NameSpace(10).Items().Count; $item = $sh.NameSpace('C:\some\parent').ParseName('victim'); $item.InvokeVerb('delete')
3 PURGE from bin (shipped fixture) -> 'ask' :: disk-hygiene flagged a Shell.Application NameSpace(10) Recycle Bin MoveHere/InvokeVerb del
command: $shell.NameSpace(0xa).ParseName($path).InvokeVerb('delete')
--- predicate breakdown ---
1 SEND to bin (folder path) bin-id-token=False action-token=True mutation-word=False
2 SEND + unrelated NameSpace(10) bin-id-token=True action-token=True mutation-word=False
3 PURGE from bin (shipped fixture) bin-id-token=True action-token=True mutation-word=False
Case 2 is the decisive control. Its deletion is byte-identical to case 1's. The only difference is
an added NameSpace(10).Items().Count, which deletes nothing — it counts the bin's contents. That
addition flips the verdict from defer to ask, which shows the gate is keyed on the presence of the
literal folder-id token, not on the deletion.
The mechanism, at destructive_guard.py:1371-1384:
_POWERSHELL_SHELL_APP_NAMESPACE_BIN = re.compile(
r"(?i)NameSpace\s*\(\s*(?:10|0x0*a)\s*\)"
)
_POWERSHELL_SHELL_APP_BIN_ACTION = re.compile(
r"(?i)(?<![\w])(?:MoveHere|InvokeVerb)\b"
)
def _is_shell_application_recycle_bin_delete(command: str) -> bool:
"""True for Shell.Application Recycle Bin MoveHere / InvokeVerb shapes."""
return (
_POWERSHELL_SHELL_APP_NAMESPACE_BIN.search(command) is not None
and _POWERSHELL_SHELL_APP_BIN_ACTION.search(command) is not None
)
Both conjuncts are required. NameSpace('<folder path>') never satisfies the first.
The obvious counter-argument fails from repo bytes. test_hygiene.py:6366-6375 deliberately
defers a non-bin MoveHere on the stated rationale that "Move-Item remains the catch-all for
rename/move cmdlets":
def test_powershell_shell_app_without_bin_action_defers(self) -> None:
"""Listing the bin is not a deletion spelling; MoveHere/InvokeVerb is required."""
for command in (
# [two bin-listing cases elided]
# MoveHere against a non-bin namespace is outside this Recycle Bin rule;
# Move-Item remains the catch-all for rename/move cmdlets.
"(New-Object -ComObject Shell.Application).NameSpace('C:\\tmp').MoveHere($path)",
):
self.assertIsNone(self.run_guard_powershell(command), command)
_POWERSHELL_MUTATION_WORDS (:1308-1313) is not that catch-all. Its alternation is
remove-item|rm|rmdir|del|erase|rd|ri|clear-content|clear-recyclebin|rimraf|unlink|sendtorecyclebin|deletefile|deletedirectory|removedirectory|move-item|rename-item|mv|move|ren|rename|set-content|out-file|add-content|format-volume|clear-disk|initialize-disk,
closed by (?![\w-]). It matches neither MoveHere (the trailing here defeats the move entry's
boundary) nor InvokeVerb at all — confirmed by the mutation-word=False column above for all three
cases, including the two the bin rule does catch.
Every shipped Shell.Application fixture that asserts "ask" uses the bin id:
test_hygiene.py:6173-6175, :6322-6323, and :6346-6350 all spell NameSpace(10) or
NameSpace(0xa). No fixture pairs a folder-path NameSpace() with InvokeVerb('delete') — the
only folder-path fixture on main is the MoveHere form at :6373, which is asserted to defer.
Why the current behavior is wrong
Recycle Bin deletion is the recoverable manual-handoff spelling the skill steers operators toward, and
the ParseName(...).InvokeVerb('delete') form is the ordinary way to write it for a named item. The
gate catches the forms that happen to mention the bin's numeric id and misses the one that addresses
the item through its parent folder — a distinction with no bearing on what gets deleted.
The failure is silent: a deferred verdict is not a refusal the operator can see. The guard returns
nothing and the command proceeds without the final human prompt every other recognized mutation
spelling raises, so the belt's stated property — that it forces a human ask before every mutation —
does not hold here. And because the gate keys on a literal token rather than on the deletion, a
command can be made to prompt by adding an inert call and silent by removing one.
Acceptance criteria
Related
- Closed issue 2595 — the original, closed by PR 2639; guard allowlist re-landed by PR 2706.
destructive_guard.py:1362-1366 — the shipped comment naming NameSpace(10) (CSIDL_BITBUCKET, also
0xa) and MoveHere/InvokeVerb as the coverage added for 2595.
- Issue 2691 — stale-base squash merges silently reverting merged fixes; the patterns above were
confirmed present in origin/main content, not inferred from 2595 being closed.
Carried-forward auditor caveats
- Windows-observed only. No Linux behavior is asserted.
- The probe drives
powershell_decision() directly rather than through a full PreToolUse payload,
so it exercises the lane's decision logic and not the hook's envelope handling.
bypassPermissions and dontAsk were not exercised.
Severity carried: High (
disk-hygiene-hooksF2) · Medium (F4) — uncalibratedProblem
Issue 2595 was closed by PR 2639 ("session-honest belt, read-only allowlist, Recycle Bin detection"),
with the guard allowlist re-landed by PR 2706 after a silent revert.
Shell.ApplicationRecycle Bindetection did ship:
plugins/disk-hygiene/skills/clean/scripts/destructive_guard.pynow carriesdedicated
NameSpace/MoveHere/InvokeVerbpatterns.The shipped gate requires the literal Recycle Bin folder id —
NameSpace(10)orNameSpace(0xa)— to appear somewhere in the command. That covers the spellings written against the bin: purging an
item already in it, and the
NameSpace(10).MoveHere($path)push form.It does not cover the shape where
NameSpace()takes the containing folder's path and the deleteverb is invoked on the item —
$sh.NameSpace('<folder path>').ParseName('victim').InvokeVerb('delete').That is the ordinary way to send a specific item to the Recycle Bin, and it still returns no verdict.
Evidence
Executed against
origin/mainat534eac1382a1e18212295210e18fbbf5a3b8f4dcon 2026-08-16, drivingpowershell_decision(command, enabled=True)from main'sdestructive_guard.py(blob OID verifiedwith
git rev-parse origin/main:<path>).Nonemeans the guard defers — no verdict, no prompt.Case 2 is the decisive control. Its deletion is byte-identical to case 1's. The only difference is
an added
NameSpace(10).Items().Count, which deletes nothing — it counts the bin's contents. Thataddition flips the verdict from defer to
ask, which shows the gate is keyed on the presence of theliteral folder-id token, not on the deletion.
The mechanism, at
destructive_guard.py:1371-1384:Both conjuncts are required.
NameSpace('<folder path>')never satisfies the first.The obvious counter-argument fails from repo bytes.
test_hygiene.py:6366-6375deliberatelydefers a non-bin
MoveHereon the stated rationale that "Move-Item remains the catch-all forrename/move cmdlets":
_POWERSHELL_MUTATION_WORDS(:1308-1313) is not that catch-all. Its alternation isremove-item|rm|rmdir|del|erase|rd|ri|clear-content|clear-recyclebin|rimraf|unlink|sendtorecyclebin|deletefile|deletedirectory|removedirectory|move-item|rename-item|mv|move|ren|rename|set-content|out-file|add-content|format-volume|clear-disk|initialize-disk,closed by
(?![\w-]). It matches neitherMoveHere(the trailingheredefeats themoveentry'sboundary) nor
InvokeVerbat all — confirmed by themutation-word=Falsecolumn above for all threecases, including the two the bin rule does catch.
Every shipped
Shell.Applicationfixture that asserts"ask"uses the bin id:test_hygiene.py:6173-6175,:6322-6323, and:6346-6350all spellNameSpace(10)orNameSpace(0xa). No fixture pairs a folder-pathNameSpace()withInvokeVerb('delete')— theonly folder-path fixture on main is the
MoveHereform at:6373, which is asserted to defer.Why the current behavior is wrong
Recycle Bin deletion is the recoverable manual-handoff spelling the skill steers operators toward, and
the
ParseName(...).InvokeVerb('delete')form is the ordinary way to write it for a named item. Thegate catches the forms that happen to mention the bin's numeric id and misses the one that addresses
the item through its parent folder — a distinction with no bearing on what gets deleted.
The failure is silent: a deferred verdict is not a refusal the operator can see. The guard returns
nothing and the command proceeds without the final human prompt every other recognized mutation
spelling raises, so the belt's stated property — that it forces a human
askbefore every mutation —does not hold here. And because the gate keys on a literal token rather than on the deletion, a
command can be made to prompt by adding an inert call and silent by removing one.
Acceptance criteria
$sh.NameSpace('<folder path>').ParseName('<name>').InvokeVerb('delete')returnspermissionDecision: "ask"rather than deferring.test_hygiene.py:6173-6175) still return"ask".NameSpace()withInvokeVerb('delete')and asserts"ask",beside the existing bin-id fixtures.
(New-Object -ComObject Shell.Application).NameSpace('C:\tmp').MoveHere($path)still defers —MoveHereinto an ordinary folder is a move, not a deletion, andtest_hygiene.py:6373mustkeep asserting
assertIsNonefor it.test_hygiene.py:6371-6372is corrected or removed:_POWERSHELL_MUTATION_WORDSmatches neither
MoveHerenorInvokeVerb, soMove-Itemis not the catch-all it claims. Ifthat gap is closed instead by widening the mutation-word set, the widening is its own change.
completeness is not implied — identity-based detection is unavailable for a COM verb.
Related
destructive_guard.py:1362-1366— the shipped comment namingNameSpace(10)(CSIDL_BITBUCKET, also0xa) andMoveHere/InvokeVerbas the coverage added for 2595.confirmed present in
origin/maincontent, not inferred from 2595 being closed.Carried-forward auditor caveats
powershell_decision()directly rather than through a fullPreToolUsepayload,so it exercises the lane's decision logic and not the hook's envelope handling.
bypassPermissionsanddontAskwere not exercised.