Skip to content

disk-hygiene: 2595's Recycle Bin gate requires a literal NameSpace(10), so the ParseName(...).InvokeVerb('delete') send-to-bin form still returns no verdict #2850

Description

@kyle-sexton

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

  • $sh.NameSpace('<folder path>').ParseName('<name>').InvokeVerb('delete') returns
    permissionDecision: "ask" rather than deferring.
  • The three currently-covered bin-id forms (test_hygiene.py:6173-6175) still return "ask".
  • A fixture pairs a folder-path NameSpace() with InvokeVerb('delete') and asserts "ask",
    beside the existing bin-id fixtures.
  • (New-Object -ComObject Shell.Application).NameSpace('C:\tmp').MoveHere($path) still defers —
    MoveHere into an ordinary folder is a move, not a deletion, and test_hygiene.py:6373 must
    keep asserting assertIsNone for it.
  • The comment at test_hygiene.py:6371-6372 is corrected or removed: _POWERSHELL_MUTATION_WORDS
    matches neither MoveHere nor InvokeVerb, so Move-Item is not the catch-all it claims. If
    that gap is closed instead by widening the mutation-word set, the widening is its own change.
  • A comment in the pattern set states that this class is enumerated, not identity-checked, so
    completeness is not implied — identity-based detection is unavailable for a COM verb.

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    needs-triageNot yet classified. Floor until a type and one priority tier are set.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions