Status: Shipped in the v0.2.0 prerelease line; stable promotion pending
downstream Netclaw validation.
Audience: Whoever (human or agent) implements, consumes, or maintains the
ShellSyntaxTree PowerShell parser.
Read SPEC.md (the bash and shared-contract specification) end-to-end
first — this document specifies only what differs for PowerShell.
This document specifies the PowerShell parser shipping in ShellSyntaxTree
v0.2.0: its grammar, tokenization, cmdlet/verb tables, alias resolution,
resolver semantics, and corpus contract. PowerShell support reuses the
shared IShellParser interface and the shared AST (ParsedCommand /
Clause / VerbChain / Arg / Redirect) defined in SPEC.md §2–§3.
It is not a PowerShell interpreter. It does not execute, expand, or
evaluate commands. It returns the same structured AST a consumer already
walks for bash. The parsing scope is Pipeline-aware (§4): linear
command pipelines parse; control flow, definitions, and other script-level
constructs mark IsUnparseable.
SPEC.md is the canonical home of the shared public API, AST, sanitization
workflow, and consumer contract. Where this spec says "see SPEC.md §N" the
referenced section applies unchanged; only the PowerShell-specific delta is
written here.
Reference dialect. This spec targets PowerShell 7.x (7.4 LTS) as the
reference dialect — the cross-platform pwsh executable, not Windows
PowerShell 5.1. The && / || pipeline-chain operators (§4, added in
PowerShell 7.0), the default alias set (§6.3), and the redirect stream
syntax (§5) are all PowerShell 7 semantics. The pwsh validation oracle
(§13) MUST run a 7.x build.
- Parse PowerShell command pipelines into the shared
ParsedCommandAST — per-clause verbs, args, parameters, redirects, compound operators — exactly the shape bash already produces. - Recognize PowerShell cmdlets (
Verb-Noun), native commands, and built-in aliases; resolve aliases to their canonical cmdlet while preserving the verbatim typed token. - Extract paths with per-cmdlet and per-parameter knowledge (
-Path,-LiteralPath,-Destination, positional rules). - Honor
Set-Location <dir>; cmdpropagation — subsequent clauses see<dir>as cwd, mirroring bashcd(SPEC.md§9). - Recurse into
pwsh -Command "<inner>",pwsh -c,pwsh -EncodedCommand <base64>, and provably staticInvoke-Expression/iexpayloads so inner command clauses surface to the consumer. - Mark dynamic-content tokens (
$var, subexpressions, script blocks, splatting) with explicitDynamicSkip/IsPath=false. - Implement
PwshParser : IShellParseralongsideBashParser— the multi-shell seam fromSPEC.md§1 is exercised for the first time.
- Parsing PowerShell scripts — control flow (
if/foreach/while/switch),function/filter/class/enumdefinitions,param()/begin/process/endblocks,trap,DATAsections. These markIsUnparseable(§11). - Parsing
.ps1script files.pwsh -File script.ps1parses as an ordinary clause; the file content is not read. - Evaluating PowerShell expressions, the pipeline variable
$_/$PSItem, or .NET method calls. - Desired State Configuration (DSC).
- Windows
cmdparsing (still deferred — seeSPEC.md§18). - Command execution and variable expansion — the same non-goals as bash
(
SPEC.md§1). The library marks dynamic tokens; it never resolves them.
The shared interface, AST records, and enums are defined in SPEC.md §2.
The additive Clause.Elements, ClauseElement, and ClauseElementRole
provenance surface applies identically to both parsers. PowerShell adds the
following parser types to namespace ShellSyntaxTree; everything else is
internal.
namespace ShellSyntaxTree;
/// <summary>
/// Shell-neutral configuration shared by every IShellParser implementation.
/// Carries the resolver knobs used to expand and normalize path tokens.
/// </summary>
public abstract record ShellParserOptions
{
/// <summary>User home directory for ~ / $HOME / $env:USERPROFILE
/// expansion. Defaults to Environment.SpecialFolder.UserProfile.</summary>
public string? HomeDirectory { get; init; }
/// <summary>Working directory for relative-path resolution. Defaults to
/// the daemon-process cwd.</summary>
public string? WorkingDirectory { get; init; }
}
/// <summary>Bash configuration. The v0.1 properties move to the base record;
/// the shape stays source-compatible — `new BashParserOptions { HomeDirectory
/// = ... }` still compiles.</summary>
public sealed record BashParserOptions : ShellParserOptions;
/// <summary>Configuration knobs for PwshParser. Empty in v0.2.0 — alias
/// resolution is unconditional (§6.3) and the resolver knobs live on the
/// shared ShellParserOptions base. Kept as a distinct type so a future
/// PowerShell-only knob is an additive change, not a new type.</summary>
public sealed record PwshParserOptions : ShellParserOptions;
/// <summary>PowerShell implementation of IShellParser.</summary>
public sealed class PwshParser : IShellParser
{
public PwshParser();
public PwshParser(PwshParserOptions options);
public ParsedCommand Parse(string command);
}The shared v0.2 AST gains the following changes (see §3):
VerbChaingains an additivestring? CanonicalVerbfield.VerbChaingains an additivebool IsDynamicfield.Clausegains the additiveElementsprovenance view shared with Bash;ClauseElementandClauseElementRoledefine its entries.Clause.IsBashCWrappedis renamedClause.IsCommandStringWrapped.
Versioning. PwshParser, PwshParserOptions, ShellParserOptions,
VerbChain.CanonicalVerb, VerbChain.IsDynamic, Clause.Elements,
ClauseElement, and ClauseElementRole are additive. The
Clause field rename and the BashParserOptions reparenting are
breaking; SPEC.md Appendix A permits a breaking AST change on a 0.x
minor bump when RELEASE_NOTES.md carries the old→new mapping and Netclaw is
updated in lockstep (§15). PublicApiSnapshotTests is updated in the same
change. PwshParser.Parse throws ArgumentNullException on null input and
never throws on a well-formed string, exactly like BashParser.
The AST records and enums are defined in SPEC.md §3 and are emitted by
PwshParser under the same shared contract — a consumer walks a PowerShell
ParsedCommand exactly as it walks a bash one. PowerShell has the following
deltas and provenance rules:
PowerShell parameters, native options, quoted/here-string values, and opaque
dynamic regions each occupy their authored position in Clause.Elements.
The leading call operator in & command and grouping parentheses are shell
syntax rather than verb/argument/redirect leaves and do not appear.
Inline parameter forms remain one source element. For -Path:C:\repo, the
element's Raw and Value describe the full parameter token while Kind,
IsPath, and Resolved describe the bound C:\repo value. Native
--flag=value follows the same rule as Bash. Backtick escapes in an inline
bound value are decoded before Value and path metadata are produced. Adjacent
native fragments such as --data='@C:\payload file' form one element because
PowerShell passes them to the executable as one argument. The complete
contiguous fragment run is consumed. Resolver-sensitive syntax inside a
single-quoted fragment mixed with expandable fragments safe-fails as
DynamicSkip rather than being expanded.
Clauses recursively surfaced from pwsh -Command and
pwsh -EncodedCommand retain inner Raw and Value but have null
SourceStart and SourceLength: quote/backtick processing, script-block
stripping, and base64 decoding do not provide a generally exact map into the
outer ParsedCommand.Source.
An outer redirect authored after a pwsh -Command or -EncodedCommand
payload remains on the surfaced wrapped clause with its exact outer source
span; only decoded inner elements have null spans.
ClauseElement.Role and PrecedingVerbElementCount mirror the shared greedy
native verb projection. They are AST coordinates, not native-executable
semantic boundaries. A PowerShell consumer applies executable-specific grammar
to the complete authored element order exactly as a Bash consumer does.
PowerShell cmdlet names, aliases, and parameter names remain
case-insensitive. Native option spelling is ordinal and reuses the shared Bash
native tables unchanged: PowerShell does not make a native executable's -c
and -C options equivalent.
/// <summary>
/// The canonical, alias-resolved verb identity, set when the parser resolved
/// the first token of Tokens from a shell built-in alias — e.g. `ls` / `gci`
/// / `dir` resolve to `Get-ChildItem`; `rm` / `del` resolve to
/// `Remove-Item`. Tokens always keeps the verbatim token the user typed;
/// this field carries the resolved name so a consumer can gate on canonical
/// identity without re-implementing the alias table.
///
/// Null when no alias resolution applied: every bash clause, and every
/// PowerShell clause whose verb is already a canonical cmdlet or an unknown
/// command. Consumers SHOULD use `CanonicalVerb ?? Tokens[0]` as the gate key.
/// </summary>
public string? CanonicalVerb { get; init; }CanonicalVerb is non-null only when an alias was rewritten. A user who
types the canonical cmdlet (Get-ChildItem) leaves it null — a non-null
value unambiguously signals "an alias was expanded."
/// <summary>
/// True when the clause's command name is a dynamic token the parser
/// cannot statically identify — a variable (`& $exe`), a subexpression
/// (`& (Get-Thing)`), or a script block (`& { ... }`) at verb position.
/// Tokens still carries the verbatim token; CanonicalVerb is null.
///
/// A consumer MUST treat a clause with IsDynamic=true as "the command being
/// run is unknown" and route to safe-fail — the verb identity, and
/// therefore every verb-keyed gate rule, is unresolvable. Always false for
/// bash clauses and for PowerShell clauses with a literal command name.
/// </summary>
public bool IsDynamic { get; init; }IsDynamic exists because PowerShell's call operator (&) makes invoking a
dynamically-named command a first-class, common idiom. A clause whose verb is
$exe or an interpolated name such as "tool-$name" is otherwise
indistinguishable from one whose verb is a literal — and
"we do not know what is being executed" is the most security-relevant state
the AST can carry, so it gets a field rather than being silently flattened
into Tokens. The clause's args and redirects still parse normally so a
consumer sees the rest of the shape.
The v0.1 field IsBashCWrapped is renamed IsCommandStringWrapped. The
meaning is unchanged and now shell-neutral: true when this clause is the
result of recursing into a command-string wrapper — bash bash -c "..." /
sh -c "...", or PowerShell pwsh -Command "..." / pwsh -c "..." /
pwsh -EncodedCommand ... / static Invoke-Expression '...' (§10).
Approximate BNF for what the PowerShell parser accepts. Anything outside this
grammar marks ParsedCommand.IsUnparseable = true (§11). PowerShell is
case-insensitive — keywords, cmdlet names, aliases, parameter names, and
drive qualifiers are all matched case-insensitively.
command := statement (statement_sep statement)*
statement_sep := ";" | "&&" | "||" | NEWLINE
statement := pipeline
pipeline := pipeline_element ("|" pipeline_element)*
pipeline_element := call_op? command_name arg* redirect*
call_op := "&" // call operator at verb position
command_name := cmdlet | native_word | quoted_string | grouped_pipeline
arg := parameter | value
parameter := "-" param_name (":" value)? // -Name value | -Name:value
| "-" param_name // switch parameter
| "--" // end-of-parameters marker
value := word | quoted_string | here_string
| script_block // { ... } -> DynamicSkip Arg
| subexpression // $( ... ) -> DynamicSkip Arg
| array_expression // @( ... ) -> DynamicSkip Arg
| hash_literal // @{ ... } -> DynamicSkip Arg
| splat // @var -> DynamicSkip Arg
redirect := redirect_op target
redirect_op := ">" | ">>" | "<"
| STREAM ">" | STREAM ">>" // STREAM in {1..6, *}
| STREAM ">&" STREAM // stream merge (2>&1)
target := word | quoted_string | "$null"
grouped_pipeline := "(" pipeline ")" // parenthesized sub-pipeline
word := run of non-whitespace, non-operator, non-quote chars,
honoring backtick escape; absorbs $var / ${name} /
$env:NAME / drive-qualified path prefixes
quoted_string := single_quoted | double_quotedNotes:
- Whitespace between tokens is one or more spaces or tabs.
- A bare newline outside quotes, here-strings,
{ },$( ),@( ),@{ }, grouping( ), and line continuations is a statement separator equivalent to;→CompoundOperator.Sequence. Consecutive, leading, and trailing newlines collapse; a newline immediately after|,&&, or||collapses (a pipeline may continue on the next line). - Backtick
`followed by a newline is a line continuation (treated as whitespace) — the PowerShell analog of bash\+ newline. &at verb position is the call operator (& git status,& $exe,& { ... }). A trailing&(a PowerShell background job) marksIsUnparseable(§11).- A clause whose command name (after an optional
&) is a dynamic token — a variable$var, a subexpression$( ... ), or a script block{ ... }— still parses, but itsVerbChain.IsDynamicis set true (§3). Its args and redirects parse normally. - A parenthesized pipeline
( ... )parses as a grouped sub-pipeline; its clauses carryIsSubshell = trueas a structural marker only. Unlike a bash subshell, PowerShell's( ... )is a grouping operator — it creates no scope and no working-directory boundary ($PWDis runspace state, not a scoped variable).Set-Locationattribution therefore propagates through( ... )rather than being isolated by it (§9). A group containing control flow marksIsUnparseable. --%is the stop-parsing token: the remainder of the line — to the next newline or end of input, including any|,;,&&, or||, which become literal text rather than operators — becomes one opaqueDynamicSkiparg.--%does not stop at a pipeline-element boundary; treating| cmdafter--%as a new clause would invent a clause that does not exist.- Script blocks
{ ... }, subexpressions$( ... ), array@( ... ), and hash@{ ... }literals are recognized as opaque tokens — the interior is not parsed; each becomes oneDynamicSkiparg. - Control-flow keywords, definition keywords, block keywords,
param(), assignment statements, bare[type]::membercalls, and bare arithmetic at statement position fall outside the grammar →IsUnparseable(§11).
The PwshLexer produces tokens consumed by PwshCommandParser. Token kinds
(PwshTokenKind):
- Word — a bare token: command name, native arg, path, number,
$var,${name},$env:PATH, drive-qualifiedC:\x. Backtick escapes are processed; simple$x/${x}is absorbed into the Word. - Parameter — a
-Nameparameter token. A-Name:valuecolon form keeps the value; the parser splits on the first:for cmdlet-style commands. Parameter names may contain internal hyphens and?, so-Name-Part,-?, and native--work-treeeach remain one token. An unquoted native--flag=valuelikewise remains one source token; the native-command parser splits it into flag and value args using the bash rules.=is not cmdlet parameter binding —-Name=valuestays one parameter token for a cmdlet. - QuotedString — single-quoted, double-quoted, or here-string.
Delimiters stripped from the value. Carries
IsSingleQuotedandIsHereStringflags. - Operator —
;,&&,||,|,&,(,), and the redirect operators. - Whitespace — spaces/tabs, or a newline run. A newline-bearing run
carries
IsStatementSeparator = true(the mechanism added in v0.1.5;SPEC.md§5). - Continuation — backtick + newline. Treated as whitespace.
- Comment —
#line comment to end-of-line, or<# ... #>block comment. Dropped by the significant-token filter. - ScriptBlock — a balanced
{ ... }region, emitted whole. Parser →DynamicSkiparg. - Subexpression — a balanced
$( ... ),@( ... ), or@{ ... }region, emitted whole. Parser →DynamicSkiparg. - Splat —
@identifier(splatting). Parser →DynamicSkiparg. - StopParsing — the
--%token; the pipeline-element remainder is opaque. - UnparseableSentinel — an unbalanced region or a lex-time-detected
unsupported construct. Carries the reason; the parser lifts it to
ParsedCommand.IsUnparseable(§11).
- Single quotes
'...'preserve bytes literally — no escape processing, no expansion. A doubled''inside is an escaped single quote. - Double quotes
"..."allow backtick escape sequences and recognize$var/${name}/$env:X/$( ... )interpolation — but the parser does not expand;$varstays literal in the token value and the resolver (§8) classifies it. A$( ... )inside a double-quoted string does not split the token. Backtick character escapes are decoded into the same logical value PowerShell passes to a command, including`u{hex}Unicode scalar escapes. - Here-strings —
@"+ newline ... newline +"@(expandable) and@'+ newline ... newline +'@(literal). The closing delimiter must start a line. Lexes to oneQuotedStringtoken withIsHereString=true. Expandable here-strings decode backtick character escapes; literal here-strings preserve their body bytes. - Unbalanced quotes or here-strings →
IsUnparseablewith a reason.
- The backtick
`is PowerShell's escape character — not a command-substitution delimiter. There is no backtick command substitution in PowerShell. Outside quotes, a backtick escape contributes its decoded character to the current Word. Inside expandable strings,`n,`t,`",`$, an escaped backtick, and`u{hex}are recognized;`edecodes to ESC. Unicode escapes accept one to six hex digits up to0x10FFFF, including UTF-16 surrogate code units as PowerShell does. A malformed Unicode escape emits an unparseable sentinel. Decoding occurs before static command-string recursion so an escaped newline cannot hide an additional command. Decoded PowerShell whitespace, including vertical tab, form feed, and Unicode separator characters, becomes a token boundary; only CR/LF separate statements. A decoded NUL marks the command unparseable rather than being merged into a verb or argument. - Backtick + newline is a line continuation.
Operators terminate the current token without surrounding whitespace —
gci|rm lexes as [gci, |, rm], exactly like bash (SPEC.md §5).
#at a token boundary starts a line comment to (but not including) the next newline — the same boundary rules as bash (SPEC.md§5 "Comment handling").#in the interior of an unquoted word (abc#def) is a literal character.<#...#>is a block comment. An unterminated block comment →UnparseableSentinel.- Comment tokens are dropped by the significant-token filter. Comment-only
input parses to
Clauses = [],IsUnparseable = false.
Recognized redirect operators, longest-match first: >, >>, <; N> and
N>> for stream N in {1,2,3,4,5,6}; *> and *>> (all streams); and
the stream-merge form N>&N (e.g. 2>&1). A redirect target of $null is
recognized as the discard sink. §8 covers how stream numbers map onto the
RedirectDirection enum.
PowerShell command names come in two shapes; the parser recognizes both.
A token is cmdlet-shaped when, case-insensitively: Kind == Word;
length in [3, 64]; it contains exactly one -; the segment before - is
an approved PowerShell verb (the closed set returned by Get-Verb —
Get, Set, New, Remove, Add, Clear, Invoke, Test, Start,
Stop, Import, Export, Select, Out, etc., held in the static
PwshApprovedVerbs table); and the segment after - begins with an ASCII
letter and is ASCII letters/digits only. Get-ChildItem, get-childitem,
and GET-CHILDITEM all match.
Gating on the approved-verb table — rather than "any letters before the
dash" — is deliberate. Hyphenated native commands (docker-compose,
apt-get, git-lfs, dotnet-counters) are not cmdlets, and their first
segment (docker, apt, git, dotnet) is not an approved verb; they
therefore fall through to the native-command path (§6.2) and keep their
multi-token subcommand chains. A pure-shape rule would misclassify them as
one-token cmdlets, truncating the chain and dropping their args from
FileVerb path classification. A hyphenated native tool whose prefix happens
to be an approved verb is a rare, accepted false positive.
A cmdlet-shaped first token (or one resolved through the alias table, §6.3)
makes the verb chain exactly one token. PowerShell cmdlets take explicit
parameters; there is no git push origin style nested-subcommand idiom for
cmdlets, so the bash greedy walk does not apply to them.
When the first token is neither cmdlet-shaped nor a known alias (git,
dotnet, npm, kubectl, python, ...) it is a native command.
Native commands reuse the bash greedy verb-chain walk (SPEC.md §6.1).
The parser appends the first token and then walks consecutive verb-like Word
tokens. The walk transparently consumes flag-with-value pairs. It stops at a
path-shaped token, non-verb-like token, flag, operator, quoted string, or
opaque token.
The path-shape test uses BashResolver.LooksLikePath. Both native parsers
therefore share one boundary. The verb-like predicate is the bash predicate
unchanged (SPEC.md §6.1:
Kind == Word, length [1, 64], first char ASCII lowercase [a-z],
remaining chars [a-z0-9._-]).
The path-shape boundary does not apply to the first native command token.
For example, deploy.sh status has verb tokens deploy.sh and status.
Keeping the predicate case-sensitive — not relaxing it to accept an
uppercase first char — is deliberate. The leading-lowercase rule is the only
signal that stops the greedy walk at a capitalized identifier
(dotnet ef migrations add InitialCreate stops at InitialCreate); relaxing
it would absorb InitialCreate into the verb chain and make the PowerShell
verb chain diverge from the bash parser's for the identical command. Real
native subcommands are lowercase in the wild (dotnet ef migrations add), so
case-sensitivity costs nothing. Cmdlet names, aliases, and parameter names
are still matched case-insensitively against their tables (§4); only the
native greedy-walk predicate stays case-sensitive. Clause.Verb remains a
convenience hint, not a security contract (SPEC.md §6.1.1); consumers
pattern-prefix match.
PwshAliases is a static, case-insensitive map from a typed alias to its
canonical cmdlet. When the first token is a known alias the parser:
- keeps the verbatim typed token in
VerbChain.Tokens(source fidelity; pattern-matching sees what was typed), and - sets
VerbChain.CanonicalVerbto the canonical cmdlet, which drives the per-cmdlet path rules (§7) and the Cwd/File verb classification (§6.4).
Alias resolution is unconditional — it is the single most
security-relevant normalization the PowerShell parser performs, and the v0.1
doctrine ("consumers can relax, they can't un-execute") means it is not a
knob. VerbChain.Tokens already preserves the verbatim token, so resolution
costs no source fidelity; a switch to disable it would only weaken
alias-keyed gate rules.
PwshAliases MUST contain the complete default alias set of the
reference PowerShell 7.x build — not a hand-picked subset. An alias absent
from the table degrades to a native command (§6.2); a file cmdlet so
degraded silently loses its per-verb path classification (§7) — a
false-negative-shaped failure in a security parser. The full set is finite
and enumerable (Get-Alias), so a [Fact] (the §13 pwsh oracle already
spawns pwsh) diffs PwshAliases against live Get-Alias output and fails
on any gap. The table below is the security-relevant excerpt (file,
cwd, and code-execution verbs), not the whole table:
| Alias(es) | Canonical cmdlet |
|---|---|
rm, del, erase, rd, rmdir, ri |
Remove-Item |
ls, dir, gci |
Get-ChildItem |
cd, chdir, sl |
Set-Location |
pushd / popd |
Push-Location / Pop-Location |
cat, gc, type |
Get-Content |
cp, copy, cpi |
Copy-Item |
mv, move, mi |
Move-Item |
ni, mkdir, md |
New-Item |
ren, rni |
Rename-Item |
ac |
Add-Content |
sls |
Select-String |
ipcsv / epcsv |
Import-Csv / Export-Csv |
gp / sp / rp |
Get-ItemProperty / Set-ItemProperty / Remove-ItemProperty |
clc / cli |
Clear-Content / Clear-Item |
rvpa |
Resolve-Path |
gi / ii |
Get-Item / Invoke-Item |
% / ? |
ForEach-Object / Where-Object |
iex |
Invoke-Expression |
echo, write |
Write-Output |
pwd, gl |
Get-Location |
select / sort / measure / group |
Select-Object / Sort-Object / Measure-Object / Group-Object |
kill / ps / sleep |
Stop-Process / Get-Process / Start-Sleep |
Keyword / alias collisions. Resolution order:
- A token at statement position that exactly equals a control-flow keyword
and is immediately followed by
(is the keyword →IsUnparseable. (foreach ($x in $y)is a loop; ingci | foreach { ... }theforeachfollows|and precedes{, so it is theForEach-Objectalias.) - Otherwise the alias table wins for known aliases.
curl,wget,sc,set,start, andwhereare treated as native commands, never aliased — their cmdlet vs. native-tool meaning is version-dependent and aliasing would mis-apply cmdlet semantics.
PwshVerbs mirrors BashVerbs, keyed by canonical cmdlet plus raw aliases
(case-insensitive):
- CwdVerbs —
Set-Location,Push-Location,Pop-Location, plus rawcd,chdir,sl,pushd,popd. (SPEC.md§6.2 already pre-listsset-location/push-location.) - FileVerbs — the file cmdlets:
Get-ChildItem,Get-Content,Set-Content,Add-Content,Remove-Item,Copy-Item,Move-Item,Rename-Item,New-Item,Get-Item,Invoke-Item,Test-Path,Resolve-Path,Out-File,Import-Csv,Export-Csv,Get-FileHash,Compress-Archive,Expand-Archive,Select-String— plus the Windows native file utilities reserved inSPEC.md§6.4 (type,copy,move,del,xcopy,robocopy,findstr). - ControlFlowKeywords —
if,elseif,else,switch,foreach,for,while,do,until,function,filter,workflow,configuration,class,enum,param,begin,process,end,dynamicparam,trap,data,try,catch,finally,return,throw,break,continue,exit,using,hidden.
PowerShell interleaves switch parameters, value-bearing parameters, and
positional values in a clause's arg list. Whether a -Name token consumes
the next space-separated token as its value depends, in real PowerShell, on
the cmdlet's compiled parameter metadata ([switch] vs. a typed
parameter). The parser has no metadata; it MUST decide binding from static
tables. §7's path classification, §9's Set-Location target rule, and
§10's pwsh -Command detection all depend on this model — it is not
optional. Without it the parser cannot even compute token boundaries:
Get-ChildItem -Recurse C:\logs is "switch -Recurse, positional path
C:\logs", but Get-ChildItem -Depth 3 C:\logs is "value-binding -Depth
with value 3, positional path C:\logs" — and only a table tells them
apart.
After the verb chain, each token has exactly one role:
- Switch — a
-Nameparameter consuming no following token. - Value-binding parameter — a
-Nameparameter consuming exactly the next significant token as its value. - Positional value — any non-parameter token.
A positional value's positional index is its running count among
positional values only. Switch tokens, value-binding-parameter tokens, and
the values those parameters consume do not advance the index. So in
Copy-Item -Force a -Verbose b, a is positional 0 and b is positional 1.
The colon form -Name:value (§5) always binds — -Name is value-binding,
value its value — regardless of the tables below.
Two case-insensitive static tables drive the decision, keyed by
(canonicalVerb, parameterName) with a verb-agnostic fallback — the same
keying §7.1 uses for the path-parameter table:
PwshValueParameters— parameters that consume the next token. Seeded with: the value-bearing common parameters (-ErrorAction,-WarningAction,-InformationAction,-ProgressAction,-ErrorVariable,-WarningVariable,-InformationVariable,-OutVariable,-OutBuffer,-PipelineVariable); every parameter in the §7.1 path-parameter table (-Path,-LiteralPath,-PSPath,-FilePath,-OutFile,-InFile,-Destination,-Source,-Filter,-Include,-Exclude,-Value,-ItemType); and-Name,-Encoding,-Depth,-Stream,-Delimiter,-Command,-EncodedCommand,-File,-ArgumentList.PwshSwitchParameters— parameters known to consume nothing: the switch common parameters (-Verbose,-Debug,-WhatIf,-Confirm) plus frequent cmdlet switches (-Recurse,-Force,-Append,-NoNewline,-PassThru,-Wait,-Quiet,-CaseSensitive,-SimpleMatch,-NoClobber,-AsByteStream,-Hidden,-Directory).
PowerShell parameter-name prefix matching applies: a -Name token
matches a table entry when it case-insensitively prefixes exactly one entry
(-Rec → -Recurse). A prefix matching two or more entries is ambiguous
and treated as unknown.
For each -Name token, in order:
- Colon form
-Name:value→ value-binding; value is the colon tail. When the name half carries an=the colon tail isDynamicSkipinstead: PowerShell reads-Path=C:\Windowsas parameter-Path=C:plus argument\Windows, a name no cmdlet can bind, so the value's role is unknowable and the parser must not classify it. -Name(prefix-)matchesPwshValueParameters→ value-binding; consume the next significant token as its value. If there is no next token, or the next token is itself a parameter or an operator,-Namebound nothing and is recorded as a switch.-Name(prefix-)matchesPwshSwitchParameters→ switch.- Unknown
-Name→ switch (consume nothing).
Rule 4's default — unknown means switch — is the security-conservative
choice. If an unknown -Name is actually value-binding, treating it as a
switch leaves its value as a positional, where §7's positional rules can
still catch a real path (IsPath=true). The opposite default (assume
value-binding) would consume that token and hide a real path from the
gate — and a missed path is the unrecoverable failure (SPEC.md §1).
Over-classifying a stray literal as a positional path is at worst a
recoverable extra prompt. This is why §7's positional rules are the floor,
not the parameter layer.
-File is a Get-ChildItem switch but pwsh -File is value-binding.
Because the tables are keyed by (canonicalVerb, parameterName), -File
resolves to value-binding when the canonical verb is pwsh / powershell
(§10) and to a switch otherwise; the verb-agnostic entries are the fallback
only when no (verb, name) row exists.
PowerShell classifies path arguments in two layers — a parameter-value
layer (dominant, because PowerShell names paths explicitly) and a
positional layer. Per-cmdlet rules are keyed by the canonical verb
(§6.3), so rm x and Remove-Item x classify identically.
Both layers operate on the token roles the §6.5 binding model has already
assigned: §6.5 decides parameter consumption and positional index; §7
decides path-ness. A §7 rule never re-derives whether a -Name consumed
the following token — it trusts §6.5.
A parameter value is a path when the parameter name (case-insensitive) is in the path-parameter set. The unambiguous, verb-agnostic names:
| Parameter | Value classification |
|---|---|
-Path, -LiteralPath, -PSPath |
path |
-FilePath, -OutFile, -InFile |
path |
-Destination, -Source |
path |
-Filter, -Include, -Exclude |
glob — Kind=Glob if metachars present, else Literal; IsPath=false |
-Value (for Set-Content / Add-Content) |
not a path (file content) |
-ItemType (for New-Item) |
not a path (literal File / Directory) |
-Name is context-dependent (a filesystem leaf for New-Item /
Rename-Item; not a path for Get-Process / Get-Service). Key the
path-parameter table by (canonicalVerb, parameterName) with the
verb-agnostic set above as the fallback — the PowerShell analog of bash's
FlagValueIsPath table (SPEC.md §7).
Most file cmdlets take -Path as positional 0; with no preceding
path-parameter, positional 0 (and beyond — positional index per §6.5.1)
classifies as a path for canonical FileVerbs. Per-cmdlet overrides:
| Canonical cmdlet | Positional rule |
|---|---|
Get-ChildItem, Get-Content, Get-Item, Remove-Item, Set-Location, Push-Location, Invoke-Item, Test-Path, Out-File, Import-Csv |
all non-flag positionals are paths |
Copy-Item, Move-Item |
positional 0 = source path, positional 1 = destination path |
Rename-Item |
positional 0 = path, positional 1 = new name (path fragment) |
New-Item |
positional 0 = path |
Set-Content, Add-Content |
positional 0 = path; -Value is content |
Select-String |
positional 0 = pattern, rest = paths (mirrors bash grep) |
ForEach-Object, Where-Object |
positional 0 is typically a script block → DynamicSkip; no path positionals |
The default for a canonical FileVerb with no override is "all non-flag
positionals are paths," exactly as SPEC.md §7.
Native commands reuse the bash per-verb rules table verbatim — git,
curl, tar, etc. behave identically to SPEC.md §7 (curl / wget:
the first positional is a URL; curl -o / -D values and Wget -o / -O
values are paths, while curl -d data is non-path unless @file requests a
file read; @- denotes stdin). Tar -F / --info-script /
--new-volume-script values execute commands and therefore safe-fail as
DynamicSkip, not paths. This
includes hyphenated option names and the bash --flag=value split: the
flag and value surface as separate args, and a curated flag's value receives
the same path classification in both parsers. Native --flag:value has no
cmdlet-binding semantics and remains verbatim. PowerShell still owns outer
tokenization: spaced curl operands beginning with @ should be quoted because
@name is splatting and bare @- is a parse error. Use forms such as
-d "@request.json" / -d "@-", or bind a file inline as
--data=@request.json, so the native command receives one value. An equals
prefix adjacent to a quoted value, such as --data='@C:\payload file', is
also one native argument and one clause element.
The resolution doctrine — single-quote bypass, DynamicSkip for anything not
statically knowable, the (ArgKind, Resolved, IsPath) result — is defined in
SPEC.md §8 and is unchanged. PwshResolver parallels BashResolver
with the PowerShell-specific steps below. Resolution order:
- Single-quote / literal here-string bypass. A token from a
single-quoted string or an
@'...'@here-string is literal bytes — skip steps 1–7. (SPEC.md§8 step 0.) ~expansion.~,~\path,~/path→HomeDirectory.~useris not supported →DynamicSkip.- Home-variable substitution.
$HOME,${HOME},$env:USERPROFILE,${env:USERPROFILE}expand toHomeDirectory.$PSScriptRoot→DynamicSkip(the script's own directory is not knowable at gate time). Every other$var/$env:NAME/${name}reference →DynamicSkipin a path slot,ArgKind.EnvVarin a non-path slot. (Mirrors bash: only the home variables are privileged.) - Provider-qualifier stripping. Strip a leading
FileSystem::orMicrosoft.PowerShell.Core\FileSystem::prefix (case-insensitive) and resolve the remainder. This generalizes the lowercasefilesystem::strip the bash resolver already performs (SPEC.md§8). - Drive-qualified paths. A single-ASCII-letter drive (
C:\,d:/foo) is a rooted filesystem path; normalize\to/for output consistency with bash. A non-FileSystem PSDrive —HKLM:,HKCU:,Env:,Cert:,Variable:,Function:,Alias:, or any qualifier longer than one letter — is not a filesystem path: classifyKind=Literal,IsPath=false. A registry or certificate "path" must not be treated as a file by a zone gate. - UNC paths.
\\server\share\...is a rooted path, normalized to//server/share/...(the bash resolver already performs this collapse). - Glob detection. Wildcard metacharacters
*,?, and[ ]→ArgKind.Globin a path slot (IsPath=true),IsPath=falseotherwise. The parser does not expand globs (SPEC.md§8). - Relative-path resolution. A token with no drive qualifier, no leading
\\, and no leading/or\is joined toPwshParserOptions.WorkingDirectory. A dynamicSet-Locationtarget makes subsequent relative pathsDynamicSkip(the working-directory- unknown mechanism,SPEC.md§9).
A redirect target of $null sets Redirect.IsDynamicSkip = true — it is
the discard sink, not a file; do not resolve it. The LooksLikePath
heuristic (SPEC.md §8) additionally recognizes a leading FileSystem:: /
Microsoft.PowerShell.Core\FileSystem:: qualifier.
Comma-separated arrays. PowerShell's , is the array operator —
-Path a.txt,b.txt binds an array of two paths. The lexer keeps a
comma-joined run as one Word (, is not an operator, §5). In a path slot, a
token with an unquoted top-level , is marked Kind=DynamicSkip, IsPath=false: the v0.2.0 parser neither splits it into per-element path args
nor resolves the mangled join (a single bogus joined path would mislead a
zone gate). Safe-fail — the consumer prompts on the raw command — is the
correct v0.2.0 behavior; per-element path extraction is a candidate v0.2.x
item (§18).
Environment-variable prompt rate. PowerShell commands reference
path-bearing variables ($env:TEMP, $env:APPDATA, $env:windir,
$env:ProgramFiles) far more often than bash commands reference $VAR
paths. With only the home variables privileged (step 2), a materially higher
share of PowerShell path args resolves to DynamicSkip than in the bash
parser. That is the intended safe failure mode — the consumer prompts — but
implementers and consumers should expect a higher prompt rate. Privileging
more $env: names is a deliberate non-goal: the value at parse time need not
match the value when the command runs.
RedirectDirection (SPEC.md §3) has five members; PowerShell has more
output streams than that. The mapping is intentionally lossy — a zone
gate cares about the redirect target path, which is preserved exactly, not
about which stream produced it:
| PowerShell redirect | RedirectDirection |
|---|---|
< |
In |
>, 1> |
Out |
>>, 1>> |
Append |
2> |
ErrOut |
2>> |
ErrAppend |
3>–6>, *> |
Out (lossy — warning/verbose/debug/information/all) |
3>>–6>>, *>> |
Append (lossy) |
stream merge N>&M (2>&1, 3>&1, ...) |
ErrOut when N is 2, else Out; Target carries &M verbatim with IsDynamicSkip=true |
Lossless stream identity is deferred (§18); v0.2.0 consumers gate on the target path, not the originating stream.
PowerShell honors the same cwd-attribution propagation bash applies to cd
(SPEC.md §9): Set-Location <dir>; cmd runs cmd with cwd <dir>.
Rules (parallel to SPEC.md §9):
- A clause whose canonical verb is
Set-Location(raw aliasescd,chdir,sl) sets the attributed cwd for subsequent clauses in the same compound. The cwd target is the value of-Path/-LiteralPathwhen present (per the §6.5 binding model), else positional 0 (positional index per §6.5.1).Set-Locationwith no positional and no-PathtargetsHomeDirectory. - Subsequent clauses receive a synthetic
ArgwithIsCwdAttribution=true. Its kind tracks theSet-Locationtarget:- target resolves to a filesystem path — a literal path,
~,$HOME,$env:USERPROFILE, a drive-qualified or UNC path, or the no-positional home case →Kind=Literal, IsPath=true,Resolvedset; subsequent relative paths in the compound resolve against it. - target is dynamic (
cd $repo), a non-FileSystem PSDrive (cd HKLM:,cd Env:— §8 step 4), orSet-Location -/Set-Location +(previous/next location, not statically knowable) →Kind=DynamicSkip, IsPath=false,Resolved=null; subsequent relative paths in the compound also becomeKind=DynamicSkip, IsPath=false— the working directory is no longer statically known (the bash dynamic-cd mechanism,SPEC.md§9).
- target resolves to a filesystem path — a literal path,
- A later
Set-Locationreplaces the attributed cwd. - Sub-pipeline
( ... )boundaries do not isolate attribution. PowerShell( ... )is a grouping operator, not a subshell — it creates no working-directory scope ($PWDis runspace state, not a scoped variable; §4). ASet-Locationinside( ... )changes the cwd for everything after the group, and attribution propagates across the group boundary. This is the one place PowerShellSet-Locationpropagation deliberately diverges from bashcd, where a subshell does isolate (SPEC.md§9 rule 4 / §10). Importing the bash isolation here would under-attribute —(cd C:\sensitive); Remove-Item *would lose the sensitive cwd — and produce a security false-negative. - Attribution is purely additive — the
Set-Locationclause and every subsequent clause keep everything the user typed, plus the synthetic arg.
Push-Location / Pop-Location (pushd / popd) are CwdVerbs — their
first non-flag positional is path-classified — but they do not add a
synthetic attribution arg in v0.2.0, mirroring bash locked interpretation #5
(SPEC.md §9). A real directory-stack model is deferred (§18).
Attribution propagates across the statement separators ;, &&, ||, and
newline. Within a single pipeline the elements share one cwd; | joins
pipeline elements within a statement, not separate statements.
Script blocks { ... }, subexpressions $( ... ), array subexpressions
@( ... ), and hash literals @{ ... } are bounded by the shared
OpaqueRegionScanner and emitted as single tokens; the parser consumes each
as one Arg { Kind=DynamicSkip, IsPath=false, Resolved=null }, Raw being
the verbatim region slice. Splatting @var is likewise DynamicSkip. The
--% stop-parsing token makes the pipeline-element remainder one
DynamicSkip arg. The interior of these regions is not parsed — this is
the heart of the Pipeline-aware scope (§4): gci | ? { ... } | rm is a
clean three-clause pipeline whose middle clause carries an opaque arg.
OpaqueRegionScanner is grammar-agnostic but escapes on backslash; for
PowerShell it is given a backtick-escape mode so { `} } scans
correctly.
pwsh -Command "<inner>" is the PowerShell analog of bash bash -c
(SPEC.md §10). The parser recognizes a clause whose verb is pwsh,
powershell, pwsh.exe, or powershell.exe (case-insensitive) carrying a
-Command parameter (the canonical -Command, the short -c, or any
unambiguous -Comm* prefix).
PowerShell's -Command consumes everything after it on the invocation,
not just one quoted token. The parser handles all three real forms:
- Quoted string —
pwsh -Command "Remove-Item C:\tmp\x". Parse the string value as a freshParsedCommand. - Script block —
pwsh -Command { Remove-Item C:\tmp\x }. Parse the script-block interior (braces stripped) as a freshParsedCommand. - Bare / multi-token —
pwsh -Command Remove-Item C:\tmp\x. Take the verbatim source slice from the first token after-Commandthrough the last command token and parse that as a freshParsedCommand.
In every form the inner clauses surface inline, each with
IsCommandStringWrapped = true. Not recognizing the bare/multi-token
form would let pwsh -Command Remove-Item C:\x leak through as opaque args
of pwsh — a verb-keyed gate would never see Remove-Item. The depth-5
recursion cap applies; deeper nesting, or an inner parse that itself
yields IsUnparseable = true (e.g. a -Command payload that decodes to a
control-flow script), sets the outer ParsedCommand.IsUnparseable = true so
the whole command routes to safe-fail (SPEC.md §10).
A terminal redirect belongs to the outer PowerShell invocation, not the child
command string. The parser appends each such redirect to the last surfaced
inner clause's Redirects and Elements; its outer source span remains exact.
Non-redirect arguments after a quoted, script-block, colon-bound, or encoded
payload are not modeled and set IsUnparseable=true rather than disappearing.
pwsh -File script.ps1 is not recursion — the file content is not
available to the parser. It parses as an ordinary clause with script.ps1
as a path arg.
pwsh -EncodedCommand <base64> (also the unambiguous -e / -enc /
-encodedc* prefixes) runs a Base64-encoded, UTF-16LE command string — the
prime way to hide a command from a security gate. The parser decodes and
recurses: it takes the single token following -EncodedCommand as the
payload (the parameter name defines it as base64 — the parser does not
heuristically sniff for "base64-shaped" tokens), Base64-decodes it,
UTF-16LE-decodes the result, strips a leading UTF-16 BOM (U+FEFF) if
present, parses the remainder as a fresh ParsedCommand, and surfaces the
inner clauses inline with IsCommandStringWrapped = true, under the same
depth-5 cap. Seeing through this obfuscation is core value for a
security-gating parser.
The BOM strip is not cosmetic: a U+FEFF left on the front of the decoded
string corrupts the first token — the verb — and the verb is the gate key.
On any failure — the token is not well-formed base64, the bytes are not
valid UTF-16LE, the decoded payload exceeds the input cap (§11), the inner
parse itself yields IsUnparseable = true, or the depth cap is exceeded —
the parser sets ParsedCommand.IsUnparseable = true with a reason naming the
failure.
Invoke-Expression and its canonical iex alias recurse only when binding
produces exactly one scalar string token whose value is statically knowable.
Static call-operator spellings such as & 'iex' ... and module-qualified
Microsoft.PowerShell.Utility\Invoke-Expression receive the same handling.
Accepted payloads are a single-quoted string, a literal here-string, a bare
non-dynamic word, or a double-quoted string / expandable here-string whose
lexer token records no unescaped variable or subexpression interpolation.
An optional exact -Command parameter may bind that one token. The normal
colon forms are accepted: -Command:Get-Date carries an inline bare value,
while -Command:'Get-Date' and -Command:"Get-Date" bind the following
quoted token. Inline values receive the same backtick decoding as ordinary
words. A # immediately after an empty colon starts a comment and therefore
leaves the required payload missing. Dynamic inline values remain opaque.
For a static payload, the parser consumes the outer expression clause and
surfaces the inner clauses inline with IsCommandStringWrapped = true. The
first inner clause takes the operator that preceded the outer expression.
Surfaced Clause.Elements retain their inner raw and decoded values but have
null source spans because their offsets cannot be mapped exactly into the
outer ParsedCommand.Source.
The parse increments the same depth counter used by pwsh -Command and
-EncodedCommand, and the payload passes through the same 64 KiB input cap.
Invoke-Expression executes in the caller's scope rather than a fresh child
process. Its inner parse therefore shares the current Set-Location context:
relative paths inherit the caller's effective location, and a static inner
Set-Location updates attribution for clauses following the expression in
the outer command. Child pwsh recursion remains isolated.
The parser never evaluates variables, interpolation, concatenation,
subexpressions, script blocks, arrays, or other computed expressions. When a
direct computed payload has a source expression, the outer expression clause
remains and the entire payload source slice becomes one
Arg { Kind=DynamicSkip, IsPath=false, Resolved=null }. Its authored
Clause.Elements retain the expression verb, an optional separate -Command
parameter, and one source-aligned DynamicSkip payload region. An inline form
such as -Command:$code remains one authored parameter element. Pipeline input,
missing payloads, and ambiguous parameter binding set
ParsedCommand.IsUnparseable = true; an incoming pipeline is dynamic even
when an explicit literal argument also appears. These rules prevent a clean,
persistently approvable Invoke-Expression clause from hiding runtime code.
Because computed code can call Set-Location in the current scope, a direct
dynamic payload also makes location attribution dynamic for every following
relative path.
The dot-source invocation operator and unsupported module-qualified cmdlets
are unparseable rather than being exposed under a misleading raw verb. The
one supported module-qualified wrapper remains
Microsoft.PowerShell.Utility\Invoke-Expression. A quoted string is a command
identity only when preceded by the call operator &; otherwise it is an
unsupported expression. Any dynamic command identity invalidates following
location attribution because it can resolve to current-scope code that calls
Set-Location.
The safe-fail contract — set IsUnparseable=true, set UnparseableReason,
return whatever clauses parsed, never throw on a well-formed string — is
defined in SPEC.md §11 and is unchanged.
PwshCommandParser sets ParsedCommand.IsUnparseable = true for:
- Lexer sentinels — unbalanced single/double quote, unterminated
here-string, unterminated
<# ... #>block comment, unbalanced{ }/$( )/@( )/@{ }, unbalanced grouping( ). - Control-flow keywords at statement/verb position —
if,elseif,else,switch,foreach(when followed by(),for,while,do,until. - Definition keywords —
function,filter,workflow,configuration,class,enum. - Block / trap / data keywords —
param,begin,process,end,dynamicparam,trap,data,try,catch,finally. - Statement keywords leading a statement —
return,throw,break,continue,exit(includingexit 0),using,hidden, and the dot-source operator.used as a statement. - Trailing
&background-job operator — a&at the end of a pipeline (not at verb position).& git statusis the call operator and parses;git status &is a background job and does not. - Assignment statement — a statement that begins
$var = .... - Bare type-literal / .NET method call — a statement that is just
[type]::Member(...), which has no verb. - PowerShell command-string recursion failure — the shared
Invoke-Expression/pwsh -Command/-EncodedCommandrecursion depth exceeds 5, an-EncodedCommandpayload fails to decode, an expression payload comes from a pipeline or cannot be bound safely, or an inner parse itself yieldsIsUnparseable(§10). - Oversized input — the command string, a static
Invoke-Expressionpayload, or a decoded-EncodedCommandpayload, exceeds the parser's input cap. The cap guards the per-shell-call hot path against a pathological or malicious input (a multi-megabyte base64 blob would otherwise decode and recurse up to five deep). The cap is a fixed internal constant — 64 KiB of UTF-16 characters, applied to the top-level input and to each decoded payload — not aPwshParserOptionsknob: a security limit a consumer can raise is not a limit. Over-cap input setsIsUnparseable = truewith a reason naming the cap. (This guard is introduced by the PowerShell parser; the bash parser may adopt the same cap in a later v0.1.x.)
Diagnostic precedence (most-informative first, mirroring SPEC.md §11):
(1) input over the size cap, checked before lexing; (2) lexer
UnparseableSentinel tokens; (3) a control-flow / definition / block keyword
at statement position; (4) a trailing & background job; (5) an assignment
or bare type-literal statement; (6) grouping ( ) balance errors or an
unexpected operator; (7) a command-string binding failure or recursion cap,
an inner-parse IsUnparseable, or an -EncodedCommand decode failure.
Consumers route an unparseable command to safe-fail exactly as for bash
(SPEC.md §11, Appendix A).
Hand-authored input / expected-AST pairs anchoring understanding. Each shows the salient AST fields; omitted fields take their documented defaults.
Input: Get-ChildItem -Path C:\logs -Recurse
Clause 0: Operator=None, Verb=[Get-ChildItem]
Args=[ {Raw="-Path", IsFlag=true},
{Raw="C:\logs", Kind=Literal, IsPath=true, Resolved="C:/logs"},
{Raw="-Recurse", IsFlag=true} ]Input: gci C:\logs
Clause 0: Operator=None, Verb=[gci], CanonicalVerb="Get-ChildItem"
Args=[ {Raw="C:\logs", Kind=Literal, IsPath=true, Resolved="C:/logs"} ]Tokens keeps the verbatim gci; CanonicalVerb carries Get-ChildItem.
Input: gci | ? { $_.Length -gt 1mb } | rm
Clause 0: Operator=None, Verb=[gci], CanonicalVerb="Get-ChildItem"
Clause 1: Operator=Pipe, Verb=[?], CanonicalVerb="Where-Object",
Args=[ {Raw="{ $_.Length -gt 1mb }", Kind=DynamicSkip, IsPath=false} ]
Clause 2: Operator=Pipe, Verb=[rm], CanonicalVerb="Remove-Item"Input: cd C:\repo; git status
Clause 0: Operator=None, Verb=[cd], CanonicalVerb="Set-Location",
Args=[ {Raw="C:\repo", IsPath=true, Resolved="C:/repo"} ]
Clause 1: Operator=Sequence, Verb=[git, status],
Args=[ {Raw="C:\repo", IsPath=true, Resolved="C:/repo",
IsCwdAttribution=true} ]Input: pwsh -Command "Remove-Item C:\tmp\x"
Clause 0: Operator=None, Verb=[Remove-Item], IsCommandStringWrapped=true
Args=[ {Raw="C:\tmp\x", IsPath=true, Resolved="C:/tmp/x"} ]Input: pwsh -EncodedCommand RwBlAHQALQBEAGEAdABlAA==
(payload decodes to `Get-Date`)
Clause 0: Operator=None, Verb=[Get-Date], IsCommandStringWrapped=trueInput: Remove-Item HKLM:\Software\X
Clause 0: Operator=None, Verb=[Remove-Item],
Args=[ {Raw="HKLM:\Software\X", Kind=Literal, IsPath=false} ]Input: foreach ($f in $list) { Remove-Item $f }
IsUnparseable=true
UnparseableReason="control-flow keyword 'foreach' is not supported in v0.2"The corpus is the acceptance contract for the parser, exactly as in
SPEC.md §13. The JSON entry schema, the file-name convention
(NN_descriptive_slug.json), and the [Theory]-driven runner are unchanged.
PowerShell-specific deltas:
PowerShell corpus entries live in
tests/ShellSyntaxTree.Tests/Corpus/powershell/*.json. Corpus files are
directory-routed by shell: an entry under Corpus/bash/ is parsed with
BashParser, an entry under Corpus/powershell/ with PwshParser. The
corpus runner and the PII audit are refactored to enumerate every
Corpus/<shell>/ directory rather than a hard-coded bash path.
The shared corpus DTO gains two optional fields:
canonicalVerb(per clause) — the expectedVerbChain.CanonicalVerb. Omit to assertnull(every bash entry, and PowerShell canonical/unknown verbs); provide the canonical cmdlet to assert an alias was resolved.oracleExpectation(per entry, meaningful only whenisUnparseable: true) —SyntaxError(genuinely malformed PowerShell — realpwshmust also reject it) orOutOfScope(valid PowerShell the parser deliberately does not model — realpwshmust accept it). Defaults toSyntaxError.OutOfScopealso covers an input that is valid PowerShell but that the parser declines for a non-grammar reason — an-EncodedCommanddecode failure, dynamic pipeline-fedInvoke-Expression, an over-cap input (§11), or a recursion-depth overflow — because realpwshparses the outer invocation without error.
| Category | Min |
|---|---|
| Simple cmdlet | 10 |
Alias resolution (assert raw Tokens + CanonicalVerb) |
15 |
| Native command / multi-token chain | 10 |
| Pipeline | 15 |
Compound / statement separator (;, &&, ` |
|
Set-Location propagation |
10 |
| Quote handling (single, double, here-string, backtick escape) | 10 |
| Parameter binding — named, positional, switch vs. value-binding (§6.5), colon-form, splat | 25 |
Redirect (including streams 1–6 / * and 2>&1) |
10 |
Command-string recursion — pwsh -Command, -EncodedCommand, and static/dynamic Invoke-Expression payloads, including nesting and location scope |
25 |
Dynamic skip ($var, $( ), glob, script block, dynamic verb & $exe) |
10 |
| Per-verb / per-parameter path rules | 10 |
Unparseable (control flow, definitions, param(), blocks, assignment, type-literal, trailing &, recursion overflow, over-cap input) |
20 |
Total minimum: 170 entries. Alias-resolution and parameter-binding are PowerShell-specific net-new categories; parameter binding (§6.5) is the hardest part of the parser and is budgeted accordingly. Strive for 200+ once seeded from sanitized real-world commands.
A CI test feeds every PowerShell corpus input to the real PowerShell parser
([System.Management.Automation.Language.Parser]::ParseInput) via a batched
child-process pwsh invocation and enforces:
isUnparseable |
oracleExpectation |
real pwsh must report |
|---|---|---|
false |
(n/a) | zero parse errors — the input is valid PowerShell |
true |
SyntaxError |
at least one parse error |
true |
OutOfScope |
zero parse errors — valid PowerShell we decline to model |
This validates corpus inputs against ground truth; it is not a
differential comparison of our AST against PowerShell's AST — a hand-authored
expected AST with a wrong parameter binding (§6.5) still passes the gate.
Author binding-category entries with extra care, and cross-check them with
tools/PwshCorpusTool, which prints, for a given command, the parser's
expected JSON block beside the real pwsh verdict; the tool is registered
in TOOLING.md. A developer without pwsh on PATH sees the gate skipped;
CI installs pwsh and an explicit step fails loudly if it is absent.
The sanitization workflow, the PII rule table, and the audit gate are defined
in SPEC.md §14 and apply to the PowerShell corpus unchanged — the audit
scans every Corpus/<shell>/ directory. PowerShell corpus seeded from real
logs carries Windows-shaped paths, so the §14 rule table and the PII-audit
regex set gain:
| Pattern | Replacement |
|---|---|
C:\Users\<username>\ (and the mixed-slash C:\Users\<username>/) |
C:\Users\user\ |
UNC \\<hostname>\share\ |
\\internal-host.example\share\ |
Windows domain DOMAIN\username |
DOMAIN\user |
A literal $env:USERNAME / $env:USERPROFILE reference is not PII (the
variable is not a value) and is left as-is; only an expanded concrete user
path is sanitized.
The CI workflows and the bare-SemVer tag convention are defined in
SPEC.md §15 and are unchanged. PowerShell-specific deltas:
- Versioning.
Directory.Build.propsVersionPrefix→0.2.0. PerSPEC.md§15, v0.2.0 is the first PowerShell parser implementation. Ship a0.2.0-alpha→0.2.0-betaprerelease so Netclaw validates the PowerShell parser and the breakingClauserename before promotion to stable0.2.0— the same beta-then-promote flow used for v0.1.5. - Test job.
dotnet testruns the bash corpus, the PowerShell corpus, thepwshvalidation gate, and the multi-shell PII audit. pwshavailability. CI installspwsh(already used for the copyright-header script); an explicit step verifies it so the validation gate never silently skips on CI.- Release notes. The
RELEASE_NOTES.mdv0.2.0 section must list the breakingClause.IsBashCWrapped→IsCommandStringWrappedrename with the old→new mapping, and the newPwshParser/PwshParserOptions/ShellParserOptions/VerbChain.CanonicalVerbsurface.
A natural order for the implementer. Each numbered item is a self-contained, testable step; most are a single PR.
- Public-API surface change (lock first). Add
ShellParserOptions, reparentBashParserOptions, addPwshParserOptions(empty record), add aPwshParserskeleton (ParsethrowsNotImplementedException), addVerbChain.CanonicalVerbandVerbChain.IsDynamic, renameClause.IsBashCWrapped→IsCommandStringWrapped. UpdatePublicApiSnapshotTests, the corpus DTOs, and theisBashCWrapped→isCommandStringWrappedkey in the existing bash corpus JSON. All existing bash tests stay green. - PowerShell verb & binding tables —
Internal/Pwsh/Verbs/:PwshApprovedVerbs(theGet-Verbset, §6.1),PwshAliases(the complete default alias set, §6.3),PwshVerbs(Cwd / File / control-flow, §6.4),PwshValueParameters/PwshSwitchParameters(the binding tables, §6.5),PwshPerVerbRules(§7). PwshLexer(Internal/Pwsh/Lexing/) — quoting, backtick escape,$var/$env:/${name}, parameters, stream redirects, statement separators, comments; opaque regions via the sharedOpaqueRegionScanner(with the backtick-escape mode). Heavy unit tests.PwshCommandParsercore — pipeline / statement splitting, verb-chain extraction, the §6.5 parameter-binding decision, args & parameters, redirects,VerbChain.IsDynamicfor dynamic command names.- Alias resolution — populate
VerbChain.CanonicalVerbunconditionally (§6.3); add the[Fact]diffingPwshAliasesagainst liveGet-Alias. PwshResolver— §8.- Per-verb / per-parameter path rules — §7.
Set-Location-in-compound propagation — §9.- PowerShell command-string recursion —
pwsh -Command,-EncodedCommand, andInvoke-Expression(§10). - Anomaly safe-fail — §11.
- Multi-shell refactor of the corpus runner and PII audit — §13 — so
both enumerate every
Corpus/<shell>/directory. This MUST precede corpus authoring: the runner cannot execute aCorpus/powershell/entry while it is hard-coded to thebashpath. - Hand-author the PowerShell corpus — §13 (≥170 entries).
pwshvalidation gate +tools/PwshCorpusTool— §13; register the tool inTOOLING.md.SPEC.mdedits — update §1 / §2 / §3 / §6.4 / §15 to reflect the shipped v0.2.0 surface, and wire CI; tag0.2.0-alphawhen green.
Most new code lives in PowerShell-only files under Internal/Pwsh/, but
some shared surface is touched and must be treated as shared — not as "bash
internals":
Internal/Lexing/OpaqueRegionScannergains a backtick-escape mode (§10) — a shared file the bash lexer also uses.- The native-command greedy walk (§6.2) and the per-verb path rules (§7.3)
are reused from the bash implementation. Reuse means a bash-side change
can regress PowerShell. Promote the reused pieces into
Internal/Shared/(or reference them deliberately) and ensure the PowerShell corpus exercises every shared path, so a bash PR cannot silently break PowerShell without a red test.
Extracting a full shared lexer/parser core is still deferred past v0.2.0 (§18) — but the incidental shared surface above is real, and "no bash internals refactored" is not an accurate description of the work.
v0.2.0 ships when all of these hold:
- The public API matches §2 —
PwshParser,PwshParserOptions,ShellParserOptions,VerbChain.CanonicalVerb,VerbChain.IsDynamic, and theClauserename.dotnet packproducesShellSyntaxTree.0.2.0.nupkg. - Every existing bash corpus entry still parses to its expected AST — v0.2.0 is a non-regression for bash.
- Every PowerShell corpus entry parses to its expected AST.
- The PowerShell corpus has ≥170 entries spanning the §13 categories,
including alias-resolution entries that assert both
TokensandCanonicalVerb, and parameter-binding entries that pin switch-vs-value decisions (§6.5). - The
pwshvalidation gate passes — every PowerShell entry is consistent with realpwshper the §13 matrix — and thePwshAliasescompleteness[Fact](§6.3) confirms the table matches liveGet-Aliasoutput. - The PII audit scans both
Corpus/bash/andCorpus/powershell/and finds zero hits. dotnet testruns on PR via GitHub Actions and passes on Linux and Windows.- Tagging
0.2.0-alphatriggerspublish_nuget.ymland the package appears on nuget.org. - Netclaw consumes the v0.2.0 package:
IShellParserresolves, theClauserename is absorbed, and at least one Netclaw integration test exercises a real PowerShell corpus entry through the live matcher and gets the expected gate decision.
- PowerShell script-level constructs — control flow,
function/class/enumdefinitions,param()/begin/process/endblocks,trap,DATA(allIsUnparseable). .ps1script-file parsing.- PowerShell expression evaluation,
$_/$PSItemsemantics, .NET method calls. - Desired State Configuration (DSC).
- A real
Push-Location/Pop-Locationdirectory-stack model (§9). - Lossless redirect-stream identity — PowerShell streams 3–6 /
*map lossily ontoRedirectDirection(§8); growing the public enum is a candidate v0.2.x item. - Per-element path extraction from a comma-separated array
(
-Path a,b,c) — v0.2.0 marks the whole tokenDynamicSkip(§8); splitting it into per-element path args is a candidate v0.2.x item. - Extracting a shared lexer/parser core from the bash and PowerShell implementations — deliberately deferred until two parsers exist so the seam is designed from real duplication, not guessed.
- Windows
cmdparsing — still deferred (SPEC.md§18).
The consumer contract is defined in SPEC.md Appendix A and is
shell-neutral — a consumer walks a PowerShell ParsedCommand exactly as it
walks a bash one. Two additions:
- When gating on verb identity, use the gate key
CanonicalVerb ?? (Tokens.Count > 0 ? Tokens[0] : null)— the index is guarded because a redirect-only clause has an emptyTokens(SPEC.md§3). An aliased PowerShell verb (rm,gci) then gates as its canonical cmdlet (Remove-Item,Get-ChildItem). - A clause with
VerbChain.IsDynamic = true(§3) has no statically-knowable verb identity — the command name is$varor a subexpression. Route it to safe-fail regardless of the gate key; no verb-pattern grant should match it.
SPEC.md Appendix B explains why ShellSyntaxTree hand-rolls its bash parser
rather than binding a native one. The same reasoning holds for PowerShell,
and one PowerShell-specific option is explicitly rejected:
PowerShell ships a full parser in System.Management.Automation
([System.Management.Automation.Language.Parser]). Binding it is tempting,
but it pulls the entire PowerShell SDK as a dependency — large, far from
AOT-trim-friendly, and contrary to the "single managed package, zero native
deps" constraint (SPEC.md §1). It also produces a full script AST: it would
happily parse function, class, and control flow that this library
deliberately marks IsUnparseable so consumers route to safe-fail.
The hand-rolled, Pipeline-aware parser keeps the v0.2.0 scope deliberate and
the package dependency-free. The real PowerShell parser still earns its keep
— as the test-time pwsh validation oracle (§13) that confirms every
corpus input is genuine PowerShell — without becoming a runtime dependency.