Status: v0.2.0 shipped; the accepted v0.3 contract adds bounded PowerShell
foreach structure plus shared command-occurrence and explicit redirect
analysis.
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 through ShellSyntaxTree v0.3:
its grammar, tokenization, cmdlet/verb tables, alias resolution, resolver
semantics, bounded control-flow analysis, and corpus contract. PowerShell
reuses the shared public API 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; stable v0.3 also supports only the explicitly bounded
foreach subset below. 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.
Explicit dialects. Existing callers default to PowerShell 7.6 servicing
releases from 7.6.4 (>=7.6.4 and <7.7), the
cross-platform pwsh executable and the dialect shipped through v0.2. Stable
v0.3 also accepts an explicit Windows PowerShell 5.1 dialect for the native
Windows fallback. The parser never infers an edition from the local machine.
The && / || pipeline-chain operators (§4), PowerShell 7 aliases and
receiver parameters, and other versioned metadata apply only in PowerShell 7
mode. Each corpus entry is validated by its matching oracle: pwsh for 7.x
and powershell.exe for 5.1 on Windows.
- 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.
Stable v0.3 adds structured projection for bounded foreach statements;
exposes iterator and body commands exactly once; derives exact or finite string
values only from proved literal
iterables; and joins location and supported binding state conservatively.
Pipeline-produced objects and unsupported expressions remain unknown without
execution. while, if, elseif, else, do, switch, definitions, and
arbitrary script evaluation stay outside the supported grammar.
The shared interface, AST records, and enums are defined in SPEC.md §2.
The additive Clause.Elements, ClauseElement, and ClauseElementRole
provenance surface and the v0.3 syntax, occurrence, value-domain, and explicit
redirect types apply 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>Compatibility option for PowerShell initial host-state analysis.</summary>
public enum PwshInitialStateMode
{
Unknown,
IsolatedNonInteractiveNoProfile,
}
/// <summary>Selects the PowerShell grammar and versioned metadata.</summary>
public enum PwshDialect
{
Unknown,
// PowerShell 7.6 servicing releases from 7.6.4.
PowerShell7,
WindowsPowerShell51,
}
/// <summary>Configuration knobs for PwshParser.</summary>
public sealed record PwshParserOptions : ShellParserOptions
{
public PwshInitialStateMode InitialStateMode { get; init; }
public PwshDialect Dialect { get; init; } = PwshDialect.PowerShell7;
}
/// <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, PwshDialect,
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.
Adding Dialect is source- and binary-additive and preserves default parser
semantics, but it participates in generated record equality, hashing,
ToString(), reflection, and default serialization. Consumers that persist
options own a versioned representation.
The consumer selects the top-level dialect from the executor it has already
chosen. new PwshParser() and an options object that omits Dialect retain
PowerShell 7 behavior. Unknown and unrecognized future enum values return an
unparseable result with empty authorization projections.
PowerShell7 denotes the contract-defined PowerShell 7.6 servicing line from
7.6.4: >=7.6.4 and <7.7. The consumer verifies both bounds before using
the dialect. Other PowerShell 7 minor lines have no v0.3 dialect value and
remain outside the supported execution contract until their grammar and
versioned metadata are independently proved.
PwshParser never delegates bash -c payloads to BashParser; Bash is an
ordinary external command in PowerShell source. Conversely, BashParser never
delegates pwsh -Command payloads to this parser. Within a PowerShell parse,
supported static PowerShell host wrappers remain parser-local: pwsh /
pwsh.exe children use PowerShell7, and powershell / powershell.exe
children use WindowsPowerShell51. A dynamic host identity remains incomplete.
PowerShell 7 keeps the existing version-pinned grammar and catalogs. Windows
PowerShell 5.1 accepts only facts proved for that edition. It rejects && and
|| as syntax errors at every recursively parsed boundary, including loop and
script-block bodies, substitutions, static expressions, and decoded child-host
payloads. It does not publish PowerShell 7-only receiver semantics
such as ForEach-Object -Parallel. Dialect-specific aliases and parameter
bindings come from versioned tables; one edition never borrows proof from the
other. In particular, unqualified curl and wget use the Windows PowerShell
5.1 Invoke-WebRequest aliases but remain native spellings in PowerShell 7.
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`), an interpolated name,
/// or a supported subexpression (`& $(Get-Thing)`) at verb position. Tokens
/// still carries the verbatim token; CanonicalVerb is null. An unsupported
/// executable identity expression makes the whole result unparseable with
/// empty command and compatibility projections instead.
///
/// 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).
The property is shared; recursion is not cross-language. BashParser sets it
only for supported Bash wrappers and PwshParser only for supported
PowerShell wrappers.
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 := static_invocation | dynamic_invocation
| direct_script_block_invocation
| supported_subexpression | grouped_pipeline
static_invocation := call_op? command_name arg* redirect*
dynamic_invocation := call_op dynamic_command_name arg* redirect*
call_op := "&" // call operator at verb position
direct_script_block_invocation := ("&" | ".") script_block
command_name := cmdlet | native_word // statically identified;
// excludes variables,
// quoted expressions, and $()
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 plus a proved,
// unknown, or absent execution region
| supported_subexpression // $( ... ) -> child commands + 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, *}
| MERGE_SOURCE ">&1" // MERGE_SOURCE in {2..6, *}
target := word | quoted_string | supported_subexpression | "$null"
supported_subexpression := "$(" command ")"
dynamic_command_name := variable | quoted_string | supported_subexpression
variable := "$" identifier | "${" variable_name "}"
| "$env:" identifier
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_quoted
// double-quoted and expandable here-string values may
// contain supported_subexpression children; literal or
// backtick-escaped spellings do notNotes:
- 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 direct& { ... }is a child-scope execution region; direct. { ... }is a current-scope dot-source execution region. Neither operator becomes a synthetic command occurrence. Dot-sourcing a file remains unparseable because the file contents are unavailable. A trailing&(a PowerShell background job) marksIsUnparseable(§11).- Under v0.2 a variable, subexpression, quoted string, or script block at
command position could be retained as a dynamic clause. Stable v0.3 aligns
this with PowerShell invocation semantics: only
& <dynamic-expression>creates an outer dynamic command occurrence. Standalone$()is an expression statement whose inner commands are exposed without inventing an invocation of its produced value. Standalone$()followed by command-style arguments is a syntax error and makes the whole result unparseable. - A parenthesized pipeline
( ... )parses as a grouped sub-pipeline; its clauses carryIsSubshell = trueas a structural marker only. PowerShell permits that grouped expression only as the first pipeline element;Get-Date | (Get-Process)is unparseable rather than a second grouped stage. The group body is one pipeline, so statement separators such as(Get-Date; Get-Process)are also unparseable. Leading and trailing newlines inside the delimiters collapse. 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 executable syntax outside the bounded v0.3 grammar marks the whole resultIsUnparseable. --%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.- The lexer preserves script blocks
{ ... }, subexpressions$( ... ), array@( ... ), and hash@{ ... }literals as bounded tokens. Under v0.2 each was one opaqueDynamicSkiparg. Stable v0.3 recursively parses every completely delimited executable$()in a supported value position and exposes its commands while retaining the containing authoredDynamicSkipleaf. Script blocks are classified after command and parameter binding: cataloged execution-bearing bindings create typed regions, cataloged data bindings remain opaque, and unknown receivers create unknown incomplete regions whose supported non-pipeline bodies remain visible. An unproved pipeline inside such a region fails atomically. An@()or@{}value with execution-bearing content is unparseable until that expression form has complete command discovery; a non-executing literal form may remain an opaque value. - Under v0.2, control-flow keywords fall outside the grammar. Stable v0.3 owns
only the contextual statement forms below. Definition keywords, unsupported
block keywords,
param(), assignment statements, bare[type]::membercalls, and bare arithmetic at statement position remain unparseable (§11).
PowerShell retains a statement-versus-pipeline distinction. foreach is a
language keyword only at statement position when followed by (;
Get-ChildItem | foreach { ... } and
Write-Output x | foreach ($_) remain command/alias syntax. An ordinary
bounded non-executing parenthesized argument remains opaque. A script-block
argument is classified by its proved receiver and parameter binding: executing
bindings create regions, proved data remains opaque, and unknown receivers
create unknown incomplete regions for supported non-pipeline bodies. An
unproved interior pipeline fails atomically. It is not reinterpreted as a loop body.
&& and || join pipelines, not
control-flow statements, so
they cannot precede or follow foreach; ; and newline remain legal statement
terminators.
pwsh_script(stop) := pwsh_statement (statement_terminator pwsh_statement)*
pwsh_statement := pwsh_foreach
| pwsh_and_or
statement_terminator := ";" | NEWLINE
pwsh_and_or := pwsh_pipeline
(("&&" | "||") pwsh_pipeline)*
pwsh_pipeline := pipeline_element ("|" pipeline_element)*
pwsh_foreach := "foreach" "(" variable "in" foreach_expression ")"
script_block_body
foreach_expression := literal_value
| literal_array
| pipeline_expression
| supported_subexpression
literal_array := "@(" literal_value ("," literal_value)* ")"
script_block_body := "{" pwsh_script(stop = "}") "}"
direct_execution_region := ("&" | ".") script_block_body
Literal scalar and literal-array iterables may produce exact or finite string
domains. A pipeline iterable exposes every producing command with role
Iterator, but its object values remain Unknown; the parser does not predict
PowerShell object-to-string conversion. A body is recursively parsed after the
structural grammar proves that the ScriptBlock token is a statement body,
direct execution region, cataloged execution-bearing argument, or conservative
unknown-receiver region. A proved non-executing script-block argument remains
one opaque DynamicSkip value and does not invent child execution.
PwshInitialStateMode.Unknown remains the default and does not publish exact or
finite loop-dependent effective values. An ambient typed, validated, read-only,
or constant binding may coerce or reject the assignment, so authored iterable
text is not a proved runtime argument. The surrounding static command
occurrence may still be complete because value precision is independent.
IsolatedNonInteractiveNoProfile asserts that the complete source runs in a
newly spawned noninteractive PowerShell process with profiles disabled and no
reused or caller-initialized runspace. It permits exact or finite values for
ordinary unscoped bindings. It does not assert a pinned module, alias, function,
PATH, or executable-resolution baseline; those runtime externalities are
outside authored-command completeness.
Only ordinary unscoped binding names that do not case-insensitively collide
with PowerShell's automatic, constant, read-only, preference, or configuration
variables are eligible. Scoped/provider bindings such as $global:x,
$script:x, $private:x, and $env:X fail the loop region closed. These
special names are visible in the submitted source and can change host behavior.
Typed and validated ambient bindings are the reason default-mode effective
values stay Unknown; the parser never reports authored text as a proved
runtime value when coercion or rejection is possible.
Parenthesized groups, $(), and static Invoke-Expression execute in the
current runspace and share supported authored binding and location state.
Decoded pwsh -Command and pwsh -EncodedCommand payloads do not inherit exact
$HOME, environment, provider, or cwd facts unless those facts are
independently proved. They do retain complete static authored command
occurrences. Path-shaped native or .ps1 spellings use their authored binding
classification; ambient alias, function, module, profile, and executable
resolution is outside the approval-grammar proof.
Recognized source-level variable, alias, function, or module mutation invalidates later affected proofs in every observing scope. A computed or otherwise hidden invocation remains incomplete and may invalidate later state; an unmodeled explicit pipeline writer fails atomically. Cwd-only mutation retains independent authored-binding facts.
Mutation is recognized from the effective parameter vector as well as the
verb. Common parameter writers -OutVariable / -ov, -PipelineVariable /
-pv, -ErrorVariable / -ev, -WarningVariable / -wv, and
-InformationVariable / -iv invalidate later observing proofs, including
accepted unambiguous prefixes such as -OutV and -PipelineV and inline forms
such as -ov:name. Command-specific writers include Tee-Object -Variable,
Import-LocalizedData -BindingVariable / -Variable,
Invoke-RestMethod -SessionVariable / -SV, -ResponseHeadersVariable /
-RHV, and -StatusCodeVariable, plus Invoke-WebRequest -SessionVariable /
-SV; their accepted unambiguous prefixes have the same effect. An opaque
splat may supply any such parameter and is therefore a possible mutation.
Because command type and custom advanced-function metadata are runtime facts,
an otherwise unclassified command carrying one of the common writer forms is
treated conservatively rather than assumed native.
When Set-Location carries a recognized writer, the writer effect composes
with both its success and failure cwd outcomes. In particular, a failure-gated
continuation after -ErrorVariable cannot retain a proved prior value merely
because location analysis selected the failure partition.
A completely delimited $() used as an ordinary word, dynamic command
identity after &, redirect value, foreach expression, double-quoted interpolation, or
expandable here-string is recursively parsed as a command substitution. Its
commands are exposed before the containing command and its produced value is
Unknown. Single-quoted strings, literal here-strings, and backtick-escaped
$() text never create substitution nodes. An unsupported execution-bearing
expression makes the whole result unparseable rather than leaving hidden
commands inside a DynamicSkip value.
A standalone $() statement has no containing simple command: the syntax block
contains the CommandSubstitutionSyntax directly and Commands contains only
commands from its body. & $(...) additionally retains one incomplete dynamic
outer occurrence after all substitution commands. Bash differs: an unquoted
command substitution in Bash command-name position contributes to the command
word, so stable v0.3 makes that runtime-dependent identity unparseable rather
than inventing a static or PowerShell-style dynamic clause.
& { ... } and . { ... } are direct execution regions. Their bodies are
recursively parsed and no synthetic outer command occurrence is created for
the invocation operator. Their typed origins are DirectCall and DotSource
respectively, so consumers never need to reparse source text to distinguish
their state behavior. & executes once synchronously in a child
variable/command scope while sharing runspace location; . executes once
synchronously in the current scope. Dot-sourcing a file remains unparseable
because the parser does not read .ps1 contents.
A script block passed to a command remains an authored DynamicSkip argument
on the host Clause. After canonical command and parameter binding, a proved
execution-bearing block additionally creates an attached
ExecutionRegionSyntax; a proved data block does not. An unknown receiver or
ambiguous binding conservatively creates an unknown incomplete region. Every
command in a completely parsed supported non-pipeline body remains visible. If
the body cannot be parsed completely, or it contains a pipeline whose stage
identity is unproved, the whole result is unparseable.
The version-pinned PowerShell 7 catalog covers:
| Receiver / parameter | Phase | Timing | Cardinality | State boundary |
|---|---|---|---|---|
direct & {} |
Main | Synchronous | Once | child variables/commands; shared location |
direct . {} |
Main | Synchronous | Once | current scope and location |
ForEach-Object -Begin |
Begin | Synchronous | Once | current runspace |
ForEach-Object -Process / -RemainingScripts |
Process, with binder-assigned Begin/End where applicable | Synchronous | OncePerInputObject | current runspace |
ForEach-Object -End |
End | Synchronous | Once | current runspace |
ForEach-Object -Parallel |
Process | Concurrent | OncePerInputObject | child runspace; runspace-local exit isolated; process-wide effects conservative |
Where-Object -FilterScript |
Filter | Synchronous | OncePerInputObject | current runspace |
in-process Invoke-Command -ScriptBlock |
Main | Synchronous | Once | child scope unless -NoNewScope; shared location |
remote/session/SSH/VM/container Invoke-Command |
Main | Synchronous for one proved target; Concurrent for multiple targets or enabled -AsJob / -InDisconnectedSession; otherwise Unknown |
Once for one proved target; otherwise Unknown | arbitrary remote state; exit isolated |
Measure-Command -Expression, Trace-Command -Expression |
Main | Synchronous | Once | current scope and location |
Start-Job -InitializationScript |
Initialization | Concurrent | Once | child process before Main |
Start-Job -ScriptBlock |
Main | Concurrent | Once | child process; exit isolated |
New-Module -ScriptBlock |
Initialization | Synchronous | Once | module state; current-runspace effects analyzed separately |
For WindowsPowerShell51, direct & { ... } and . { ... } retain the
grammar- and state-proved semantics above, and a statically authored
Write-Output { ... } remains proved non-executing data. Other command-owned
script blocks remain visible but incomplete with Unknown execution and state
facts until their 5.1 receiver and binder metadata is independently
oracle-proved. This deliberately prevents the 5.1 dialect from inheriting the
PowerShell 7 catalog merely because command spellings overlap.
Remote Invoke-Command bodies begin with Unknown working directory and
host-dependent values. Their static authored command occurrences remain
complete; local parser state is not an inheritance proof for a remote host or
persistent session, and remote exit state never flows into the invoking host
continuation. A complete
literal, quoted, URI, GUID, or hashtable target proves one activation. A
complete top-level comma-separated target list, whether named, inline, or
positional, proves concurrent scheduling but maps to public cardinality
Unknown, because the public enum intentionally has no once-per-target value.
Quoted, backtick-escaped, or nested commas remain part of one target value. A
dynamic target or session collection leaves both
timing and cardinality Unknown unless an enabled -AsJob or
-InDisconnectedSession independently proves concurrent scheduling. Explicit
:$false switch values do not prove concurrency.
Parallel child runspaces do not inherit ordinary caller variables or aliases,
and their runspace-local variable and location exit state does not flow into the
host continuation. They do share process-wide state such as the environment
provider. A supported or unknown child mutation that may affect such state
invalidates later host binding, command-resolution, and location facts. It also
invalidates later child-activation facts even with -UseNewRunspace, because a
fresh runspace is not a fresh process.
Runspace global: variable, function, alias, and location state remains local
to that runspace and does not by itself invalidate the host. Once child command
resolution is mutated, however, an exact changed alias or function name is
retained in bounded case-insensitive state. A later matching invocation whose
identity is no longer proved is a possible process-wide mutation and receives
the same conservative invalidation unless its identity is independently proved.
An ambiguous changed name, wildcard, or candidate-set overflow collapses to all
unproved command names rather than guessing.
Optional-module Start-ThreadJob and deferred breakpoint, event, and argument-
completion receivers are not stable-v0.3 catalog-completeness promises.
Existing conservative recognition may remain, but additional module/version or
trigger-time proof does not gate the release. Every unproved form follows the
unknown-receiver rule: supported non-pipeline bodies remain visible with
incomplete execution and state facts, while an unproved interior pipeline
fails atomically.
Aliases, supported module-qualified spellings, static call-operator spellings,
parameter abbreviations and inline values, positional binding, parameter-set
selection, and ScriptBlock[] binding resolve through the same static catalog.
Catalog lookup classifies the authored command for approval; it is not a claim
about ambient runtime resolution. A catalog entry may classify a script block
as non-executing data unless an explicit source-level mutation has invalidated
that authored receiver proof. Mutation matching covers both the authored
spelling and its known canonical alias target.
PowerShell's special multiple-script-block binding for ForEach-Object assigns
Begin, Process, and End phases semantically; authored syntax and occurrence
projection remain in source order while the analyzer schedules phases in
runtime order.
ExecutionRegionTiming and ExecutionRegionCardinality are not scope facts.
Variable, location, command-resolution, runspace, and process propagation are
analyzed independently. Cataloged receivers may retain already-proved
scheduling facts without making further catalog expansion release-gating.
Unproved receiver, binding, or authored state facts remain Unknown rather than
borrowing runtime state. A static authored Write-Output { Remove-Item x }
remains opaque data and does not invent a Remove-Item occurrence.
A leading param(...) declaration inside any execution region remains outside
the stable-v0.3 body grammar and makes the whole parse unparseable. This
deliberately limits realistic argument-completer and directly invoked blocks
until parameter declaration and block-argument binding are modeled together.
PowerShell scope and location state remain shell-specific. Grouping ( ... )
does not isolate location. Foreach exits include the zero-iteration state. The
parser does not publish a finite cwd set.
foreach assignments use a case-insensitive persistent binding map rather than
lexical push/pop restoration. A proved nonempty ordered iterable leaves its
last assigned value after the loop; a same-name nested loop overwrites that
value. A proved empty iterable performs no body transition and preserves the
incoming binding and location. A zero-or-more iterable joins the zero path with
all reachable iteration exits. Repeated visits join facts for each authored
occurrence, and all concrete or fixed-point visits share the parse-wide 4096
transition budget.
Set-Location has separate success and failure transfers: success takes the
proved filesystem target location, while failure retains the incoming location.
A successful non-filesystem or unproved target invalidates binding and
command-resolution proofs in addition to making cwd unknown. &&
continues from success, || from failure, and statement sequence consumes
their join. $() and parenthesized groups share current-runspace state;
decoded child hosts isolate their exit state. Unsupported directory-stack or
state/command-resolution mutations, including Import-Alias,
Import-PSSession, and New-Module, remain fail
closed. Provider-capable item
mutators invalidate binding proofs when their target provider is not proved;
dynamic values alone do not invalidate a proved filesystem target. Loop parsing uses cloned
compatibility attribution so an unreachable body cannot leak a parse-time
location, and a possibly reached mutation cannot leave a false exact path.
Outcome projection also rebases cwd-dependent compatibility arguments, clause
elements, redirects, and attribution to an exact occurrence cwd. Unknown joins
clear those resolutions and retain the <dynamic-cwd> marker. Explicit
redirect targets also become Unknown unless provenance proves that the target
is cwd-independent; an unreachable body never borrows the parse-time cwd.
Decoded child
hosts retain inherited invocation-cwd attribution on their compatibility
leaves while isolating child exit state. A redirect authored outside the
decoded wrapper payload is evaluated in the invocation scope before child
launch, so its target may use a bounded parent-loop binding; redirects authored
inside the decoded payload continue to use child scope. When decoded child
hosts are nested, an outer redirect retains the outermost invocation scope
that authored it rather than binding to the nearest decoded child scope.
Stable v0.3 continues to defer while, if, elseif, else, do, switch,
functions, definitions, class/type bodies, and arbitrary execution-bearing
expressions outside the bounded forms above.
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. Command arguments retain aDynamicSkipcompatibility arg. Structural binding may additionally recurse into it as a direct, cataloged, or conservative unknown execution region; proved data remains opaque. - Subexpression — a balanced
$( ... ),@( ... ), or@{ ... }region, emitted whole. The v0.3 structural parser recursively lowers supported$()regions and retains the outer compatibility arg asDynamicSkip; unsupported execution-bearing array/hash expressions fail closed. - 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 compatibility token, but v0.3 recursively exposes its commands throughSimpleCommandSyntax.Substitutions. 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 and v0.3 discovers every executable$()interpolation; literal here-strings preserve their body bytes and never create substitution commands. - 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 forms N>&1 for source stream N in {2,3,4,5,6} and
*>&1. PowerShell reserves < for future use, success stream 1 cannot be
a merge source, and merge targets other than success stream 1 are syntax
errors. A redirect target of $null or ${null} is recognized as the discard
sink. §8 covers how stream numbers map onto the RedirectDirection enum.
One command may redirect each source at most once. The unnumbered output
source and explicit stream 1 are the same source; a merge and a file redirect
also conflict when they consume the same numbered source. * remains its own
source and may coexist with a numbered redirect, matching native PowerShell.
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 owns static, case-insensitive maps from a typed alias to its
canonical cmdlet for each supported dialect. When the first token is a known
alias in the selected dialect 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 within the selected dialect — 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 each supported
dialect — 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 the §13 oracle gate diffs each dialect table
against live output from its matching executable and fails on any missing
alias or mismatched canonical definition. A dialect table MUST NOT borrow an
alias that exists only in another supported edition. Platform- or SKU-specific
aliases may remain in the static table when the selected dialect can define
them, because a CI host may not expose every optional Windows component. The
table below is the security-relevant common 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 selected dialect's alias table wins for known aliases.
- In PowerShell 7,
curl,wget,sc,set,start, andwhereremain native commands. Windows PowerShell 5.1 instead applies its default aliases, includingcurl/wget→Invoke-WebRequest,sc→Set-Content,set→Set-Variable,start→Start-Process, andwhere→Where-Object.
Get-Error and its gerr alias are PowerShell 7-only and MUST NOT be
resolved in the WindowsPowerShell51 dialect. Conversely, Windows PowerShell
5.1-only aliases such as CFS → ConvertFrom-String, gwmi →
Get-WmiObject, asnp → Add-PSSnapIn, and trcm → Trace-Command remain
edition-specific. md and man are
normalized to the effective cmdlet reached through their default helper
functions (New-Item and Get-Help) while the authored alias token remains
unchanged.
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.
PowerShell accepts U+2013 EN DASH, U+2014 EM DASH, and U+2015 HORIZONTAL BAR
in place of the leading ASCII parameter dash. Stable v0.3 deliberately fails
those forms atomically. The retained v0.2 Arg.IsFlag member derives from an
ASCII - in verbatim Arg.Raw; treating an alternate dash as a positional
literal is unsafe, while normalizing Raw would violate source provenance.
Support therefore requires a later additive representation that can preserve
both facts.
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 script blocks remain DynamicSkip compatibility args and additionally bind typed execution regions; no path positionals |
The default for a canonical FileVerb with no override is "all non-flag
positionals are paths," exactly as SPEC.md §7.
After selected-dialect alias resolution, commands that remain native reuse the
bash per-verb rules table verbatim — git, PowerShell 7 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.
Windows PowerShell 5.1 curl / wget do not reach these native rules because
their dialect aliases bind to Invoke-WebRequest; its cmdlet parameter rules
apply instead. Explicit curl.exe remains native.
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 or ${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.
The ordered v0.2 resolution steps describe compatibility results, but they do not permit v0.3 to reconstruct expansion from decoded text. The PowerShell front end retains each ordered literal, typed-expansion, or opaque fragment, its exact-or-null source span, allowed lexical transforms, expansion identity, cardinality, and opaque cause through decoding. Raw spelling, decoded logical values, and source spans keep their v0.2 meanings.
Resolution receives one explicit consumer context:
- native argument;
- cmdlet
Pathbinding; - cmdlet
LiteralPathbinding; - redirect target.
Native arguments apply PowerShell lexical quote rules but never acquire
cmdlet provider or PSDrive semantics merely because their decoded text looks
provider-qualified. A quoted native ~, *.txt, or
FileSystem::C:\logs\x remains literal. An unquoted native wildcard remains
unknown without filesystem enumeration.
Cmdlet Path and redirect contexts apply tilde, wildcard, FileSystem
provider, and PSDrive semantics after value formation even when quoted.
LiteralPath suppresses wildcard interpretation but still applies quoted
tilde, provider, and PSDrive semantics. An unproved PSDrive mapping or wildcard
cardinality remains unknown. A drive-relative value such as C:foo is not an
absolute filesystem path proof and fails closed.
Escaped interpolation starts remain exact literals. Recognized special,
numeric, scoped, braced, and Unicode-named variables retain typed expansion
identity while their value stays unknown without a bounded proof. An
unterminated ${...} interpolation makes the entire result unparseable.
Adjacent fragments after a redirect operator form one target; the suffix is
never emitted as an unrelated argument.
Exact $HOME and $env:USERPROFILE composition requires the corresponding
current-runspace or environment fact. A decoded child with uncontrolled profile
startup has neither fact even when its parent was analyzed under the isolated
mode. Native-versus-cmdlet path interpretation uses the authored command
spelling and parser tables; ambient runtime shadowing does not erase that
classification. An explicit source-level mutation may invalidate it.
When every fragment, transformation, binding fact, cwd/home fact, and
consumer fact is exact, mixed literal and expandable fragments compose to one
exact compatibility result rather than becoming DynamicSkip. Any opaque or
incompletely mapped region, unknown required fact, or unsupported transform
fails closed. The resolver does not recursively rescan produced text.
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 |
|---|---|
>, 1> |
Out |
>>, 1>> |
Append |
2> |
ErrOut |
2>> |
ErrAppend |
3>–6>, *> |
Out (lossy — warning/verbose/debug/information/all) |
3>>–6>>, *>> |
Append (lossy) |
stream merge N>&1 for N in 2–6, or *>&1 |
ErrOut when N is 2, else Out; Target carries &1 verbatim with IsDynamicSkip=true |
The table above remains the v0.2 Redirect compatibility mapping. v0.3 also
populates the closed RedirectAnalysis family:
RedirectSource.PowerShellAllStreams preserves *,
RedirectSource.Descriptor preserves numeric streams, and runtime alternatives
distinguish FileRedirectAnalysis output/append from static
DescriptorDuplicateRedirectAnalysis. Descriptor alternatives are not paths.
PowerShell's grammar does not
admit descriptor close, move, computed merge targets, or file input
redirection; those spellings make the whole parse unparseable. $null and
${null}
remain an incomplete explicit redirect until the public operation vocabulary
has a discard-sink representation; consumers must continue to fail closed.
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 { ... }, array subexpressions @( ... ), and hash literals
@{ ... } are bounded by the shared OpaqueRegionScanner. PowerShell command
subexpressions $( ... ) use a specialized scanner that understands nested
subexpressions, quoted regions, backtick escapes, line and block comments, and
the shared structural-depth cap. Direct $() bodies recognize line comments
only at PowerShell word boundaries. A $() discovered while decoding an expandable
string or here-string rejects comment-bearing interiors conservatively because
the parent quoting context changes whether PowerShell can close the region.
Here-strings nested inside $() remain an unsupported grammar boundary. Each
bounded region is emitted as one token. The compatibility parser
retains each as one Arg { Kind=DynamicSkip, IsPath=false, Resolved=null },
Raw being the verbatim region slice. Stable v0.3 additionally parses every
supported executable $() interior into SimpleCommandSyntax.Substitutions.
Execution-bearing @() / @{} forms that cannot be completely discovered
make the whole result unparseable. Splatting @var remains DynamicSkip.
The --% stop-parsing token makes the pipeline-element remainder one
DynamicSkip arg. Script-block arguments additionally pass through the §4
receiver/binding catalog. Thus gci | ? { Test-Path $_ } | rm retains a
three-clause compatibility pipeline, and the middle clause owns a Filter
execution region whose body commands are projected. A canonical receiver
proved to consume a block as data retains only the opaque arg. An unknown
receiver retains an unknown incomplete region rather than asking consumers to
decide whether hidden commands execute.
PowerShell $() runs in the current runspace scope. A Set-Location inside a
subexpression affects later inner commands, the containing command after value
evaluation, and following outer commands. Unknown location mutations propagate
as unknown. Because Set-Location can fail, an ungated statement sequence also
joins the prior location; a success-gated && continuation may use the proved
new location. The v0.2 compatibility leaf remains authored evidence, while the
v0.3 occurrence analysis is the failure-aware security fact and sanitizes stale
exact compatibility attribution. This differs from Bash command substitution,
whose state is isolated from the containing shell.
OpaqueRegionScanner is grammar-agnostic but escapes on backslash; the
PowerShell script-block, array, and hash paths give it a backtick-escape mode
so { `} } scans correctly. The specialized $() scanner applies the
same backtick behavior directly.
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.
When the complete command-string production is not proved, the parser retains
the authored outer host clause but sets its command occurrence
IsComplete=false. This includes dynamic or quoted wrapper-control input,
--%, stdin-driven -Command -, and command-string-capable forms outside the
locked grammar such as -CommandWithArgs / -cwa. The same incomplete-outer
rule applies to a computed Invoke-Expression payload. These leaves preserve
compatibility evidence; they are never sufficient authorization evidence and
must not be mistaken for proof that no hidden command can execute.
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 mutate variables, aliases, functions, modules, and
location in the current scope, a direct dynamic payload invalidates every
following binding and command-resolution proof and makes location attribution
dynamic for every following relative path. The same rule applies to iex, a
static call-operator spelling, and the supported module-qualified spelling.
The dot-source invocation operator remains unparseable except for one inline,
completely delimited script block. Dot-sourced files/dynamic values and
unsupported module-qualified cmdlets are unparseable rather than being exposed
under a misleading raw verb. This
validation applies independently to every simple command inside structural
lists, pipelines, loops, groups, and substitutions, including built-in
cmdlets such as Tee-Object whose verb is not in the approved-verb table. The
supported module-qualified exceptions are
Microsoft.PowerShell.Utility\Invoke-Expression and the version-pinned §4
execution-region receiver catalog under its authored-receiver contract. 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 v0.3 safe-fail contract — set IsUnparseable=true, set
UnparseableReason, return empty Commands and Clauses, retain at most a
diagnostic Syntax tree, and never throw on a well-formed string — is defined
in SPEC.md §11.
PwshCommandParser sets ParsedCommand.IsUnparseable = true for:
- Lexer sentinels — unbalanced single/double quote, unterminated
here-string, unterminated
<# ... #>block comment, unbalanced{ }/$( )/@( )/@{ }, unbalanced grouping( ). - Unsupported control-flow at statement/verb position —
switch,for,do,until,if,elseif,else, orwhile, plus anyforeachform outside the bounded stable-v0.3 grammar in §4. - 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, andhidden. The call and dot-source operators support only one completely delimited inline script block.& { ... } argand. { ... } argremain unparseable until block-argument binding is modeled.. ./script.ps1and a dynamic dot-source target remain unparseable because their executable content is unavailable. - 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: switch -Wildcard ($value) { '*.txt' { Remove-Item $_ } }
IsUnparseable=true
UnparseableReason="PowerShell switch is unsupported in v0.3"
Commands=[]
Clauses=[]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/ is parsed with PwshParser
using its optional dialect field. Omitting that field preserves PowerShell 7. 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 three 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 — the selected real shell must also reject it) orOutOfScope(valid PowerShell the parser deliberately does not model — the selected real shell must 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 the selected real shell parses the outer invocation without error. The same rule applies when ShellSyntaxTree decodes a static child-host orInvoke-Expressionpayload and then rejects syntax inside it: the oracle sees only the authored outer command, where that payload is still data, so the entry isOutOfScoperather thanSyntaxError.powerShellDialect(per entry) — selectsPowerShell7orWindowsPowerShell51for bothPwshParserOptions.Dialectand the live oracle. Omit to preserve the PowerShell 7 behavior of every existing entry. Unknown and unrecognized enum values are not silently defaulted.
The shared v0.3 syntax and commands expectations defined in SPEC.md §13
apply unchanged. Selected PowerShell entries SHALL pin current-scope groups,
pipeline/list precedence, exact direct spans, null decoded-wrapper spans,
command roles, ancestry, completeness, and exact compatibility-leaf identity.
The manifest marks these entries explicitly so regeneration preserves the
structural assertions; entries without the marker retain the legacy schema.
| 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 matching real PowerShell parser
([System.Management.Automation.Language.Parser]::ParseInput) via a batched
child process: pwsh for PowerShell7 and powershell.exe for
WindowsPowerShell51. It enforces:
isUnparseable |
oracleExpectation |
selected real shell 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 dialect-matched 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 selected-shell verdict; the tool is registered
in TOOLING.md. A developer without an oracle executable sees only that
dialect's gate skipped. CI requires pwsh on both platforms and requires
Windows PowerShell 5.1 on Windows, where the 5.1 corpus is validated.
The same dialect-matched gate compares every live Get-Alias name and
definition with the parser table. It fails for a missing alias or a different
canonical command. Static platform/SKU supersets are permitted, but a dialect
must not inherit an alias known to belong only to another supported edition.
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; script-block/array/hash regions via the sharedOpaqueRegionScanner(with the backtick-escape mode), and$()via its specialized scanner. 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 control flow outside stable v0.3's bounded
foreachsubset, includingwhile,if,elseif,else,do, andswitch. function/filter/class/enumdefinitions,param()/begin/process/endblocks,trap, andDATA..ps1script-file parsing.- General PowerShell expression evaluation,
$_/$PSItemsemantics, .NET method calls, object-to-string prediction, and runtime pipeline evaluation. - Desired State Configuration (DSC).
- A real
Push-Location/Pop-Locationdirectory-stack model (§9). - Per-element path extraction from a comma-separated array
(
-Path a,b,c) — v0.2.0 marks the whole tokenDynamicSkip(§8); splitting arbitrary parameter arrays remains independently gated. - A shared lexer, structural parser base class, or false shared expression grammar. Only post-parse machinery proven identical in both shells is extracted through explicit adapters.
- Windows
cmdparsing — still deferred (SPEC.md§18).
The consumer contract is defined in SPEC.md Appendix A and is
shell-neutral. Security consumers enumerate every CommandOccurrence and
evaluate every explicit redirect. PowerShell adds these identity rules:
- 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.