diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md
index df651ce..05a18b2 100644
--- a/IMPLEMENTATION_PLAN.md
+++ b/IMPLEMENTATION_PLAN.md
@@ -50,6 +50,10 @@ priorities.
### Completed maintenance
+- [x] **Issue #63 — static Invoke-Expression command-string recursion.**
+ Recurse into provably static `Invoke-Expression` / `iex` payloads,
+ safe-fail computed and pipeline-fed code, share the existing recursion
+ limits, and preserve current-scope PowerShell location attribution.
- [x] **Issue #64 — path-shaped operands after native verb chains.**
Stop the Bash and PowerShell native greedy passes before a token that
matches the shared path-shape rules. Preserve that token as a resolved
diff --git a/README.md b/README.md
index 5f540f2..e3ce725 100644
--- a/README.md
+++ b/README.md
@@ -166,6 +166,8 @@ Mermaid flowchart in your browser. Everything runs client-side — pasted
scripts never leave your machine. Useful for "what does this script
actually do?" moments and for understanding how the library models
constructs like subshells and `bash -c` / `pwsh -Command` recursion.
+Static `Invoke-Expression` / `iex` payloads surface the same way, while
+computed payloads safe-fail.
```bash
dotnet run --project samples/ShellSyntaxTree.Web.Sample
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index b8ef622..2f2f88a 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -1,3 +1,16 @@
+#### Unreleased ####
+
+## Added
+
+- **Surfaced static `Invoke-Expression` payloads for security gates (#63)**
+ `PwshParser` now recurses into provably static `Invoke-Expression` / `iex`
+ strings, preserves current-scope `Set-Location` attribution, and applies the
+ existing command-string size and depth limits. Variables, interpolation,
+ concatenation, subexpressions, and pipeline input now route through
+ `DynamicSkip` or `IsUnparseable` instead of producing a clean persistent
+ approval shape. PowerShell backtick and Unicode escapes are decoded before
+ recursion, and exact colon-form `-Command:` binding is supported.
+
#### 0.2.0-beta.1 2026-07-22 ####
## Fixed
diff --git a/SPEC.POWERSHELL.md b/SPEC.POWERSHELL.md
index d589e0c..0f9ddd5 100644
--- a/SPEC.POWERSHELL.md
+++ b/SPEC.POWERSHELL.md
@@ -47,9 +47,10 @@ syntax (§5) are all PowerShell 7 semantics. The `pwsh` validation oracle
`-LiteralPath`, `-Destination`, positional rules).
4. Honor `Set-Location
; cmd` propagation — subsequent clauses see
`` as cwd, mirroring bash `cd` (`SPEC.md` §9).
-5. Recurse into `pwsh -Command ""`, `pwsh -c`, and
- `pwsh -EncodedCommand ` so inner command clauses surface to the
- consumer.
+5. Recurse into `pwsh -Command ""`, `pwsh -c`,
+ `pwsh -EncodedCommand `, and provably static
+ `Invoke-Expression` / `iex` payloads so inner command clauses surface to
+ the consumer.
6. Mark dynamic-content tokens (`$var`, subexpressions, script blocks,
splatting) with explicit `DynamicSkip` / `IsPath=false`.
7. Implement `PwshParser : IShellParser` alongside `BashParser` — the
@@ -180,7 +181,8 @@ 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` is otherwise indistinguishable from one whose verb is a literal — and
+`$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
@@ -192,7 +194,7 @@ 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 ...` (§10).
+`pwsh -EncodedCommand ...` / static `Invoke-Expression '...'` (§10).
---
@@ -319,19 +321,32 @@ The `PwshLexer` produces tokens consumed by `PwshCommandParser`. Token kinds
`$var` / `${name}` / `$env:X` / `$( ... )` interpolation — but the parser
**does not expand**; `$var` stays literal in the token value and the
resolver (§8) classifies it. A `$( ... )` inside a double-quoted string
- does not split the token.
+ does not split the token. Backtick character escapes are decoded into the
+ same logical value PowerShell passes to a command, including
+ `` `u{hex}`` Unicode scalar escapes.
- **Here-strings** — `@"` + newline ... newline + `"@` (expandable) and
`@'` + newline ... newline + `'@` (literal). The closing delimiter must
start a line. Lexes to one `QuotedString` token with `IsHereString=true`.
+ Expandable here-strings decode backtick character escapes; literal
+ here-strings preserve their body bytes.
- Unbalanced quotes or here-strings → `IsUnparseable` with a reason.
### Escape handling
- The backtick `` ` `` is PowerShell's escape character — **not** a
command-substitution delimiter. There is no backtick command substitution
- in PowerShell. Outside quotes, `` `X `` takes `X` literally into the
- current Word. Inside double quotes, `` `n ``, `` `t ``, `` `" ``, `` `$ ``,
- and an escaped backtick are recognized.
+ 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; `` `e`` decodes to ESC. Unicode escapes accept one to six hex
+ digits up to `0x10FFFF`, 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.
### Operator boundaries
@@ -804,7 +819,7 @@ pipeline elements within a statement, not separate statements.
---
-## 10. Subexpression & `pwsh -Command` Recursion
+## 10. Subexpression & Command-String Recursion
### Opaque regions
@@ -879,11 +894,53 @@ failure.
### `Invoke-Expression`
-`Invoke-Expression` / `iex` is **not** recursed into — evaluating its string
-argument requires PowerShell expression semantics, which is out of scope. It
-parses as an ordinary clause; its argument is a normal `Arg` (`DynamicSkip`
-when it is `$var` or `$( ... )`). A consumer that wants to gate dynamic code
-execution can hard-deny the verb `Invoke-Expression` / `iex` itself.
+`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.
+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 }`. Pipeline input,
+missing payloads, and ambiguous parameter binding set
+`ParsedCommand.IsUnparseable = true`; an incoming pipeline is dynamic even
+when an explicit literal argument also appears. These rules prevent a clean,
+persistently approvable `Invoke-Expression` clause from hiding runtime code.
+Because computed code can call `Set-Location` in the current scope, a direct
+dynamic payload also makes location attribution dynamic for every following
+relative path.
+
+The dot-source invocation operator and unsupported module-qualified cmdlets
+are unparseable rather than being exposed under a misleading raw verb. The
+one supported module-qualified wrapper remains
+`Microsoft.PowerShell.Utility\Invoke-Expression`. A quoted string is a command
+identity only when preceded by the call operator `&`; otherwise it is an
+unsupported expression. Any dynamic command identity invalidates following
+location attribution because it can resolve to current-scope code that calls
+`Set-Location`.
---
@@ -914,10 +971,13 @@ defined in **`SPEC.md` §11** and is unchanged.
7. **Assignment statement** — a statement that begins `$var = ...`.
8. **Bare type-literal / .NET method call** — a statement that is just
`[type]::Member(...)`, which has no verb.
-9. **`pwsh` recursion failure** — `pwsh -Command` / `-EncodedCommand`
- recursion depth exceeds 5, an `-EncodedCommand` payload fails to decode,
- or an inner parse itself yields `IsUnparseable` (§10).
-10. **Oversized input** — the command string, or a decoded `-EncodedCommand`
+9. **PowerShell command-string recursion failure** — the shared
+ `Invoke-Expression` / `pwsh -Command` / `-EncodedCommand` recursion depth
+ exceeds 5, an `-EncodedCommand` payload fails to decode, an expression
+ payload comes from a pipeline or cannot be bound safely, or an inner parse
+ itself yields `IsUnparseable` (§10).
+10. **Oversized input** — the command string, a static `Invoke-Expression`
+ payload, or a decoded `-EncodedCommand`
payload, 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
@@ -934,8 +994,8 @@ defined in **`SPEC.md` §11** and is unchanged.
`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) the `pwsh` recursion cap, an inner-parse
-`IsUnparseable`, or an `-EncodedCommand` decode failure.
+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).
@@ -1059,9 +1119,9 @@ The shared corpus DTO gains two optional fields:
parser deliberately does not model — real `pwsh` must accept it). Defaults
to `SyntaxError`. `OutOfScope` also covers an input that is valid
PowerShell but that the parser declines for a non-grammar reason — an
- `-EncodedCommand` decode failure, an over-cap input (§11), or a
- recursion-depth overflow — because real `pwsh` parses the *outer*
- invocation without error.
+ `-EncodedCommand` decode failure, dynamic pipeline-fed
+ `Invoke-Expression`, an over-cap input (§11), or a recursion-depth overflow
+ — because real `pwsh` parses the *outer* invocation without error.
### Coverage targets for v0.2.0
@@ -1076,7 +1136,7 @@ The shared corpus DTO gains two optional fields:
| 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 |
-| `pwsh -Command` / `-EncodedCommand` recursion — incl. bare/script-block `-Command` forms and adversarial `-EncodedCommand` payloads (bad base64, BOM, decodes-to-control-flow, nested) | 15 |
+| 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 |
@@ -1181,7 +1241,8 @@ testable step; most are a single PR.
6. **`PwshResolver`** — §8.
7. **Per-verb / per-parameter path rules** — §7.
8. **`Set-Location`-in-compound propagation** — §9.
-9. **`pwsh -Command` / `-EncodedCommand` recursion** — §10.
+9. **PowerShell command-string recursion** — `pwsh -Command`,
+ `-EncodedCommand`, and `Invoke-Expression` (§10).
10. **Anomaly safe-fail** — §11.
11. **Multi-shell refactor** of the corpus runner and PII audit — §13 — so
both enumerate every `Corpus//` directory. This MUST precede
diff --git a/SPEC.md b/SPEC.md
index f68b38e..7800d7d 100644
--- a/SPEC.md
+++ b/SPEC.md
@@ -220,7 +220,8 @@ public sealed record Clause
///
/// True when this clause is the result of recursing into a
/// command-string wrapper — `bash -c "..."` / `sh -c "..."`, or (v0.2.0)
- /// PowerShell `pwsh -Command "..."` / `pwsh -EncodedCommand ...`. Useful
+ /// PowerShell `pwsh -Command "..."` / `pwsh -EncodedCommand ...` /
+ /// static `Invoke-Expression '...'`. Useful
/// for consumers that want to surface "this came from a wrapped
/// invocation" in UI.
///
@@ -256,7 +257,8 @@ public sealed record VerbChain
///
/// True when the clause's command name is a dynamic token the parser
- /// cannot statically identify — `& $exe`, `& { ... }` (added v0.2.0).
+ /// cannot statically identify — `& $exe`, `& "tool-$name"`,
+ /// `& { ... }` (added v0.2.0).
/// Always false for bash clauses. See SPEC.POWERSHELL.md §3.
///
public bool IsDynamic { get; init; }
diff --git a/openspec/changes/recurse-static-invoke-expression/.openspec.yaml b/openspec/changes/recurse-static-invoke-expression/.openspec.yaml
new file mode 100644
index 0000000..1b062d3
--- /dev/null
+++ b/openspec/changes/recurse-static-invoke-expression/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-08-04
diff --git a/openspec/changes/recurse-static-invoke-expression/design.md b/openspec/changes/recurse-static-invoke-expression/design.md
new file mode 100644
index 0000000..77cab97
--- /dev/null
+++ b/openspec/changes/recurse-static-invoke-expression/design.md
@@ -0,0 +1,147 @@
+## Context
+
+`PwshCommandParser` already expands `pwsh -Command` and
+`pwsh -EncodedCommand`, marks surfaced clauses as command-string wrapped, and
+enforces a 64 KiB input cap plus a depth-five recursion cap.
+`Invoke-Expression` currently parses as an ordinary cmdlet or alias clause.
+That leaves a static destructive payload hidden from verb-based security
+policy and permits a persistent approval for the wrapper to authorize later
+payloads.
+
+Unlike a child `pwsh` process, `Invoke-Expression` executes in the caller's
+scope. Its payload inherits the current PowerShell location, and a
+`Set-Location` inside the payload affects commands that follow outside it.
+The public AST is locked, so the implementation must express uncertainty
+through the existing `DynamicSkip` and `IsUnparseable` signals.
+
+## Goals / Non-Goals
+
+**Goals:**
+
+- Surface clauses from provably static `Invoke-Expression` and `iex`
+ payloads.
+- Prevent variables, interpolation, concatenation, subexpressions, pipeline
+ input, and other computed expressions from producing a clean approval
+ shape.
+- Preserve location state into and out of static expression recursion.
+- Reuse one input-size and recursion-depth budget for every PowerShell
+ command-string execution construct.
+- Keep the public API unchanged.
+
+**Non-Goals:**
+
+- Evaluate PowerShell expressions, variables, concatenation, arrays, or
+ pipeline values.
+- Parse script blocks passed to `Invoke-Expression` as static strings.
+- Change the fresh-process scope rules for `pwsh -Command` or
+ `pwsh -EncodedCommand`.
+- Add source positions or a new public uncertainty type.
+
+## Decisions
+
+### Recognize the canonical command identity
+
+The parser will identify the full `Invoke-Expression` cmdlet name and the
+`iex` alias through one canonical-verb check. The behavior will not depend on
+the spelling used by the caller.
+
+### Require one statically provable scalar string
+
+A payload is static only when binding produces exactly one scalar string
+token and the token needs no PowerShell expression evaluation. Accepted
+forms are a single-quoted string, a literal here-string, a bare non-dynamic
+word, or an expandable string/here-string for which the lexer observed no
+unescaped interpolation.
+
+Multiple tokens, script blocks, subexpressions, splats, arrays, and operators
+are computed expressions even when their parts happen to be literals. The
+parser will not concatenate or otherwise evaluate them.
+
+The lexer will add internal interpolation provenance to quoted-string tokens.
+This distinguishes ``"`$name"`` from `"$name"` after escape processing.
+Searching the processed token value for `$` was rejected because it would
+misclassify escaped literal dollar signs and lose the proof the lexer already
+has while scanning source text.
+
+Backtick character escapes are decoded before a static payload is parsed,
+including Unicode scalar and newline escapes. This matches the logical string
+PowerShell passes to `Invoke-Expression`; preserving the source escape text
+could otherwise hide an inner verb or location change.
+
+### Use DynamicSkip when the dynamic source is observable
+
+For a direct computed payload, the parser will preserve the outer
+`Invoke-Expression` verb and collapse the complete payload source slice into
+one `Arg { Kind = DynamicSkip, IsPath = false, Resolved = null }`. Returning
+the current collection of literal-looking expression fragments was rejected
+because a consumer could mistake it for a stable approval shape.
+
+Computed code can call `Set-Location` in the current scope. The parser will
+therefore mark the shared location context dynamic after a computed payload,
+preventing later relative paths from resolving against stale attribution.
+
+Pipeline-only, missing, or ambiguously bound payloads will mark the outer
+`ParsedCommand` unparseable. A synthetic `` argument was
+rejected because `Arg.Raw` promises a verbatim source token.
+
+Any incoming pipeline makes the expression invocation dynamic, even when an
+explicit literal argument is also present, because runtime pipeline values
+can contribute additional invocations.
+
+### Expand static payloads in place
+
+For a static payload, the outer expression clause is consumed and the inner
+clauses are inserted in its place. Every inner clause has
+`IsCommandStringWrapped = true`; the first inner clause inherits the operator
+that preceded the outer clause. This matches the existing command-string
+visibility contract without adding an expression-specific AST property.
+
+### Share location context for Invoke-Expression only
+
+`ParseInternal` will accept an internal location context. Top-level parsing
+creates it, `Invoke-Expression` passes the same instance into the inner parse,
+and child `pwsh` recursion continues to create an isolated context.
+
+Sharing the context gives static expression payloads both required
+directions of current-scope behavior:
+
+- Relative paths inside the payload resolve against the caller's effective
+ location.
+- A `Set-Location` inside the payload updates attribution for following outer
+ clauses.
+
+The surfaced inner clauses receive attribution while they are parsed; the
+outer expansion must not attach it a second time.
+
+### Share one recursion counter and input cap
+
+`Invoke-Expression`, `pwsh -Command`, and `pwsh -EncodedCommand` will all
+increment the existing PowerShell command-string recursion counter. A mixed
+chain therefore cannot bypass the depth-five cap by alternating wrapper
+types. Every static payload is parsed through `ParseInternal`, which applies
+the existing 64 KiB character cap before lexing.
+
+## Risks / Trade-offs
+
+- **Bare words can differ from quoted strings under full PowerShell binding.**
+ The static form is limited to one non-dynamic token; any multi-token or
+ operator-bearing shape safe-fails.
+- **Current-scope location mutation is more coupled than child-process
+ recursion.** The shared context is internal and used only for
+ `Invoke-Expression`; tests will pin isolation for child `pwsh` recursion.
+- **Conservative rejection can cause additional prompts.** This is the
+ preferred failure mode for a security parser; false negatives are
+ recoverable and false approvals are not.
+- **The outer `Invoke-Expression` verb disappears for static payloads.**
+ `IsCommandStringWrapped` preserves wrapper provenance, while consumers see
+ the executable inner verbs they need to gate.
+
+## Migration Plan
+
+No consumer migration is required. The change is additive parser behavior
+within the existing AST. A package rollback restores the prior behavior.
+
+## Open Questions
+
+None. The safe-fail representation, static proof boundary, location scope,
+and shared resource limits are locked by this change.
diff --git a/openspec/changes/recurse-static-invoke-expression/proposal.md b/openspec/changes/recurse-static-invoke-expression/proposal.md
new file mode 100644
index 0000000..70f7572
--- /dev/null
+++ b/openspec/changes/recurse-static-invoke-expression/proposal.md
@@ -0,0 +1,38 @@
+## Why
+
+The PowerShell parser exposes code hidden by `pwsh -Command` and
+`-EncodedCommand`, but leaves equivalent static code behind
+`Invoke-Expression` / `iex` opaque. Security consumers can therefore approve
+the wrapper without evaluating the command it executes.
+
+## What Changes
+
+- Recurse into `Invoke-Expression` and `iex` only when the complete payload is
+ provably a static string.
+- Surface computed payloads as `DynamicSkip`; safe-fail pipeline-only,
+ missing, and ambiguous payloads rather than returning a clean approval
+ shape.
+- Preserve the caller's PowerShell location while parsing static payloads and
+ propagate location changes made by the payload back to following clauses.
+- Share the existing 64 KiB input cap and depth-five recursion budget across
+ `Invoke-Expression`, `pwsh -Command`, and `pwsh -EncodedCommand`.
+- Preserve the current public API.
+
+## Capabilities
+
+### New Capabilities
+
+- `invoke-expression-recursion`: Safely expose static PowerShell expression
+ strings while preserving current-scope location and safe-failing computed
+ payloads.
+
+### Modified Capabilities
+
+None.
+
+## Impact
+
+The change affects the PowerShell lexer and command parser, PowerShell unit
+tests and corpus entries, `SPEC.POWERSHELL.md`, shared wrapper documentation
+in `SPEC.md`, release notes, and the implementation plan. It adds no public
+API, package dependency, or native dependency.
diff --git a/openspec/changes/recurse-static-invoke-expression/specs/invoke-expression-recursion/spec.md b/openspec/changes/recurse-static-invoke-expression/specs/invoke-expression-recursion/spec.md
new file mode 100644
index 0000000..7f74be2
--- /dev/null
+++ b/openspec/changes/recurse-static-invoke-expression/specs/invoke-expression-recursion/spec.md
@@ -0,0 +1,204 @@
+## ADDED Requirements
+
+### Requirement: Invoke-Expression recurses into provably static command strings
+
+The PowerShell parser SHALL recurse into the payload of `Invoke-Expression` and its canonical `iex` alias only when binding produces exactly one scalar string token whose value is statically knowable.
+The parser SHALL consume the outer expression clause, surface the inner clauses,
+set `IsCommandStringWrapped = true` on every surfaced clause, and preserve
+the outer clause's preceding operator on the first surfaced clause.
+
+#### Scenario: Full cmdlet name with a literal payload
+- **WHEN** PowerShell parses `Invoke-Expression 'Get-Date'`
+- **THEN** the result contains one `Get-Date` clause
+- **THEN** the clause has `IsCommandStringWrapped` set to `true`
+- **THEN** no outer `Invoke-Expression` clause is returned
+
+#### Scenario: Alias with a static destructive payload
+- **WHEN** PowerShell parses `iex 'Remove-Item C:\x'`
+- **THEN** the result contains a canonical `Remove-Item` clause
+- **THEN** `C:\x` is surfaced as its path argument
+
+#### Scenario: Expandable string without interpolation is static
+- **WHEN** PowerShell parses `iex "Get-Date"`
+- **THEN** the result contains one wrapped `Get-Date` clause
+
+#### Scenario: Bare scalar word is static
+- **WHEN** PowerShell parses `Invoke-Expression Get-Date`
+- **THEN** the result contains one wrapped `Get-Date` clause
+
+#### Scenario: Command parameter binds a static payload
+- **WHEN** PowerShell parses `Invoke-Expression -Command 'Get-Date'`
+- **THEN** the result contains one wrapped `Get-Date` clause
+
+#### Scenario: Colon command parameter binds a static payload
+- **WHEN** PowerShell parses `Invoke-Expression -Command:Get-Date`
+- **THEN** the result contains one wrapped `Get-Date` clause
+
+#### Scenario: Inner compound clauses are surfaced
+- **WHEN** PowerShell parses `iex 'Get-Date; Get-Process'`
+- **THEN** the result contains wrapped `Get-Date` and `Get-Process` clauses
+- **THEN** the second clause has the `Sequence` operator
+
+#### Scenario: Outer operator is preserved
+- **WHEN** PowerShell parses `Get-Process; iex 'Get-Date'`
+- **THEN** the surfaced `Get-Date` clause has the `Sequence` operator
+
+### Requirement: Computed expression payloads never produce a clean approval shape
+
+When an `Invoke-Expression` payload depends on runtime computation, the parser MUST NOT recurse into it.
+When the complete source expression is observable, the parser SHALL retain
+the outer canonical command identity and
+SHALL surface the complete payload source slice as one
+`Arg { Kind = DynamicSkip, IsPath = false, Resolved = null }`. When the
+payload is missing, comes from an incoming pipeline, or cannot be bound
+unambiguously, the parser SHALL set `ParsedCommand.IsUnparseable = true`.
+
+#### Scenario: Variable payload is dynamic
+- **WHEN** PowerShell parses `Invoke-Expression $code`
+- **THEN** the result retains an `Invoke-Expression` clause
+- **THEN** `$code` is one `DynamicSkip` argument
+
+#### Scenario: Interpolated payload is dynamic
+- **WHEN** PowerShell parses `iex "Remove-$noun C:\x"`
+- **THEN** the result retains the canonical `Invoke-Expression` identity
+- **THEN** the complete quoted payload is one `DynamicSkip` argument
+
+#### Scenario: Subexpression payload is dynamic
+- **WHEN** PowerShell parses `iex $(Get-Content script.ps1)`
+- **THEN** the complete payload expression is one `DynamicSkip` argument
+
+#### Scenario: Concatenated literals are dynamic
+- **WHEN** PowerShell parses `iex ('Get-' + 'Date')`
+- **THEN** the parser does not concatenate or recurse into the expression
+- **THEN** the complete payload expression is one `DynamicSkip` argument
+
+#### Scenario: Pipeline-only payload is unparseable
+- **WHEN** PowerShell parses `Get-Content script.ps1 | Invoke-Expression`
+- **THEN** `ParsedCommand.IsUnparseable` is `true`
+- **THEN** the diagnostic identifies dynamic pipeline input
+
+#### Scenario: Incoming pipeline dominates an explicit payload
+- **WHEN** PowerShell parses `'Get-Date' | Invoke-Expression 'Get-Process'`
+- **THEN** `ParsedCommand.IsUnparseable` is `true`
+- **THEN** the parser does not return a clean static expression expansion
+
+#### Scenario: Missing payload is unparseable
+- **WHEN** PowerShell parses `Invoke-Expression`
+- **THEN** `ParsedCommand.IsUnparseable` is `true`
+
+### Requirement: Expandable-string staticness uses lexical interpolation evidence
+
+The PowerShell lexer SHALL distinguish unescaped variable or subexpression interpolation from escaped literal dollar signs in double-quoted strings and expandable here-strings.
+The parser SHALL use that lexical evidence when proving an
+`Invoke-Expression` payload static.
+
+#### Scenario: Variable interpolation is detected
+- **WHEN** PowerShell lexes `"Get-$noun"`
+- **THEN** the quoted-string token records interpolation
+
+#### Scenario: Subexpression interpolation is detected
+- **WHEN** PowerShell lexes `"Get-$(Get-Variable noun)"`
+- **THEN** the quoted-string token records interpolation
+
+#### Scenario: Escaped dollar is literal
+- **WHEN** PowerShell parses ``iex "Write-Host `$name"``
+- **THEN** the quoted payload is treated as static
+- **THEN** the inner `Write-Host` clause is surfaced
+
+#### Scenario: Unicode escape in an invoked alias is decoded
+- **WHEN** PowerShell parses ``& "i`u{65}x" $code``
+- **THEN** the command is recognized as `iex`
+- **THEN** `$code` is surfaced as `DynamicSkip`
+
+#### Scenario: Escaped newline in a static payload is decoded
+- **WHEN** a static expression payload contains `` `n`` between two commands
+- **THEN** both inner command clauses are surfaced
+
+#### Scenario: Decoded PowerShell whitespace separates inner tokens
+- **WHEN** a static expression payload separates a verb and path with
+ vertical tab, form feed, or Unicode whitespace
+- **THEN** the path is surfaced as a separate inner argument
+
+#### Scenario: Colon payload comment leaves the argument missing
+- **WHEN** PowerShell parses `Invoke-Expression -Command:#comment`
+- **THEN** `ParsedCommand.IsUnparseable` is `true`
+
+#### Scenario: Decoded NUL safe-fails
+- **WHEN** a static expression payload contains a decoded NUL
+- **THEN** `ParsedCommand.IsUnparseable` is `true`
+
+#### Scenario: Scoped interpolation is dynamic
+- **WHEN** a static-looking payload contains `$:name`
+- **THEN** the payload is surfaced as `DynamicSkip`
+
+#### Scenario: Unsupported invocation identity safe-fails
+- **WHEN** an expression payload invokes a quoted command without `&` or an unsupported module-qualified cmdlet
+- **THEN** `ParsedCommand.IsUnparseable` is `true`
+
+#### Scenario: Dynamic command invalidates location
+- **WHEN** a dynamic command identity executes before a relative path clause
+- **THEN** the following path and cwd attribution are dynamic
+
+#### Scenario: Malformed Unicode escape safe-fails
+- **WHEN** a command string contains an empty or out-of-range `` `u{...}`` escape
+- **THEN** `ParsedCommand.IsUnparseable` is `true`
+
+### Requirement: Static expression recursion shares the caller's location context
+
+The parser SHALL parse a static `Invoke-Expression` payload using the caller's effective PowerShell location.
+Location changes made by the static payload SHALL update attribution for
+clauses that follow in the containing
+scope. Child `pwsh` recursion SHALL retain its existing isolated location
+context.
+
+#### Scenario: Static payload inherits caller location
+- **WHEN** PowerShell parses `Set-Location C:\a; iex 'Remove-Item child.txt'`
+- **THEN** the surfaced `Remove-Item` path resolves under `C:\a`
+
+#### Scenario: Payload location change affects following outer clause
+- **WHEN** PowerShell parses `iex 'Set-Location C:\b'; Remove-Item child.txt`
+- **THEN** the outer `Remove-Item` path resolves under `C:\b`
+
+#### Scenario: Payload compound uses and exports its final location
+- **WHEN** PowerShell parses `Set-Location C:\a; iex 'Set-Location C:\b; Remove-Item child.txt'; Get-ChildItem child.txt`
+- **THEN** the inner `Remove-Item` path resolves under `C:\b`
+- **THEN** the following outer `Get-ChildItem` path resolves under `C:\b`
+
+#### Scenario: Child pwsh location remains isolated
+- **WHEN** PowerShell parses `pwsh -Command 'Set-Location C:\b'; Remove-Item child.txt` with a different outer working directory
+- **THEN** the outer `Remove-Item` does not inherit `C:\b`
+
+#### Scenario: Dynamic expression invalidates following location
+- **WHEN** PowerShell parses `Set-Location C:\safe; iex $code; Remove-Item child.txt`
+- **THEN** the following `child.txt` path is `DynamicSkip`
+- **THEN** the following clause carries dynamic cwd attribution
+
+### Requirement: PowerShell command-string constructs share security limits
+
+`Invoke-Expression`, `pwsh -Command`, and `pwsh -EncodedCommand` SHALL share the existing depth-five PowerShell command-string recursion counter.
+Every static expression payload SHALL be subject to the existing 64 KiB input
+cap.
+An over-limit payload or an inner parse that is unparseable SHALL mark the
+outer `ParsedCommand` unparseable.
+
+#### Scenario: Five nested expression strings parse
+- **WHEN** PowerShell parses five nested static `Invoke-Expression` payloads
+- **THEN** the innermost clauses are surfaced
+- **THEN** `ParsedCommand.IsUnparseable` is `false`
+
+#### Scenario: Six nested expression strings exceed the cap
+- **WHEN** PowerShell parses six nested static `Invoke-Expression` payloads
+- **THEN** `ParsedCommand.IsUnparseable` is `true`
+- **THEN** the diagnostic identifies the depth-five recursion cap
+
+#### Scenario: Mixed wrappers share one depth budget
+- **WHEN** a command alternates `Invoke-Expression`, `pwsh -Command`, and `pwsh -EncodedCommand` beyond five total levels
+- **THEN** `ParsedCommand.IsUnparseable` is `true`
+
+#### Scenario: Oversized static payload is rejected
+- **WHEN** a static `Invoke-Expression` payload exceeds 64 KiB of UTF-16 characters
+- **THEN** `ParsedCommand.IsUnparseable` is `true`
+
+#### Scenario: Inner anomaly propagates outward
+- **WHEN** a static `Invoke-Expression` payload parses as an unsupported control-flow script
+- **THEN** `ParsedCommand.IsUnparseable` is `true`
diff --git a/openspec/changes/recurse-static-invoke-expression/tasks.md b/openspec/changes/recurse-static-invoke-expression/tasks.md
new file mode 100644
index 0000000..8117bef
--- /dev/null
+++ b/openspec/changes/recurse-static-invoke-expression/tasks.md
@@ -0,0 +1,38 @@
+## 1. Contract
+
+- [x] 1.1 Replace the `Invoke-Expression` exclusion in `SPEC.POWERSHELL.md` section 10 with static recursion and dynamic safe-fail rules.
+- [x] 1.2 Specify current-scope location propagation, shared recursion failures, and corpus coverage in `SPEC.POWERSHELL.md` sections 10, 11, and 13.
+- [x] 1.3 Update the shared command-string wrapper documentation in `SPEC.md` without changing the public API.
+
+## 2. Lexical Evidence
+
+- [x] 2.1 Record unescaped interpolation on double-quoted strings and expandable here-strings.
+- [x] 2.2 Add lexer tests for variable interpolation, subexpression interpolation, escaped dollar signs, and static expandable strings.
+
+## 3. Parser
+
+- [x] 3.1 Recognize `Invoke-Expression` and `iex` through one canonical command identity.
+- [x] 3.2 Recurse into one provably static positional or `-Command` payload and surface wrapped inner clauses.
+- [x] 3.3 Collapse direct computed payloads into one `DynamicSkip` source argument.
+- [x] 3.4 Mark pipeline-only, incoming-pipeline, missing, and ambiguously bound payloads unparseable.
+- [x] 3.5 Share the existing input cap and depth-five counter across expression, command, and encoded-command recursion.
+- [x] 3.6 Share location context with expression recursion so inner location changes affect following outer clauses while child `pwsh` remains isolated.
+
+## 4. Verification
+
+- [x] 4.1 Add parser unit tests for full-name, alias, literal, parameter, compound, operator, and wrapper cases.
+- [x] 4.2 Add parser unit tests for interpolation, variables, concatenation, subexpressions, pipeline input, missing payload, and inner anomalies.
+- [x] 4.3 Add parser unit tests for inherited location, exported location changes, child-process isolation, depth limits, mixed wrappers, and input limits.
+- [x] 4.4 Replace PowerShell corpus case 157 and add the required static, dynamic, pipeline, location, and recursion cases to `CorpusManifest`.
+- [x] 4.5 Regenerate the PowerShell corpus and confirm the real-`pwsh` oracle matrix remains valid.
+- [x] 4.6 Cover Unicode and backtick-newline escape decoding found during security review.
+- [x] 4.7 Cover exact colon-form `Invoke-Expression -Command:` binding.
+- [x] 4.8 Cover decoded whitespace, colon comments, and escaped inline colon payloads found during follow-up review.
+- [x] 4.9 Cover decoded NUL safe-fail and inline colon Unicode escapes.
+- [x] 4.10 Cover dot invocation, scoped interpolation, module qualification, dynamic cwd, quoted expressions, and malformed escapes from adversarial review.
+- [x] 4.11 Cover quoted call-operator module-qualified cmdlets from adversarial re-review.
+
+## 5. Completion
+
+- [x] 5.1 Update `RELEASE_NOTES.md` and `IMPLEMENTATION_PLAN.md` for issue #63.
+- [x] 5.2 Run OpenSpec validation, Release build, full tests, and copyright-header verification.
diff --git a/src/ShellSyntaxTree/Clause.cs b/src/ShellSyntaxTree/Clause.cs
index 330c6ac..ffd2527 100644
--- a/src/ShellSyntaxTree/Clause.cs
+++ b/src/ShellSyntaxTree/Clause.cs
@@ -49,7 +49,8 @@ public sealed record Clause
/// 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 .... Useful for consumers that want to
+ /// pwsh -EncodedCommand ... / static
+ /// Invoke-Expression '...'. Useful for consumers that want to
/// surface "this came from a wrapped invocation" in UI. See
/// SPEC.POWERSHELL.md §3 / §10.
///
diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Lexing/PwshLexer.cs b/src/ShellSyntaxTree/Internal/Pwsh/Lexing/PwshLexer.cs
index 273b472..878e889 100644
--- a/src/ShellSyntaxTree/Internal/Pwsh/Lexing/PwshLexer.cs
+++ b/src/ShellSyntaxTree/Internal/Pwsh/Lexing/PwshLexer.cs
@@ -51,11 +51,20 @@ internal static IReadOnlyList Tokenize(string input)
{
var c = src[i];
+ if (c == '\0')
+ {
+ tokens.Add(new PwshToken(
+ PwshTokenKind.UnparseableSentinel,
+ "", null, i, 1,
+ $"NUL character is not supported at position {i}"));
+ return tokens;
+ }
+
// ---- whitespace ----
- if (c == ' ' || c == '\t')
+ if (IsInlineWhitespace(c))
{
var start = i;
- while (i < src.Length && (src[i] == ' ' || src[i] == '\t'))
+ while (i < src.Length && IsInlineWhitespace(src[i]))
{
i++;
}
@@ -349,6 +358,7 @@ private static int ReadDoubleQuoted(
// interpolation, but the parser does NOT expand — $var stays literal
// in the value (SPEC §5).
var sb = new StringBuilder();
+ var hasInterpolation = false;
var i = start + 1;
while (i < src.Length)
{
@@ -365,29 +375,28 @@ private static int ReadDoubleQuoted(
tokens.Add(new PwshToken(
PwshTokenKind.QuotedString, sb.ToString(), null,
- start, (i - start) + 1, null));
+ start, (i - start) + 1, null)
+ { HasInterpolation = hasInterpolation });
return i + 1;
}
if (c == '`' && i + 1 < src.Length)
{
- var n = src[i + 1];
- sb.Append(n switch
+ if (IsMalformedUnicodeEscape(src, i))
{
- 'n' => '\n',
- 't' => '\t',
- 'r' => '\r',
- '0' => '\0',
- 'a' => '\a',
- 'b' => '\b',
- 'f' => '\f',
- 'v' => '\v',
- _ => n,
- });
- i += 2;
+ tokens.Add(InvalidUnicodeEscapeToken(src, i));
+ return src.Length;
+ }
+
+ i = AppendBacktickEscape(src, i, sb);
continue;
}
+ if (c == '$' && StartsInterpolation(src, i))
+ {
+ hasInterpolation = true;
+ }
+
sb.Append(c);
i++;
}
@@ -505,14 +514,31 @@ private static int TryReadHereString(
bodyEnd--;
}
- var body = bodyEnd >= bodyStart
- ? src.Slice(bodyStart, bodyEnd - bodyStart).ToString()
- : string.Empty;
+ var bodySpan = bodyEnd >= bodyStart
+ ? src.Slice(bodyStart, bodyEnd - bodyStart)
+ : ReadOnlySpan.Empty;
+ var hasInterpolation = false;
+ var invalidUnicodeAt = -1;
+ var body = quote == '"'
+ ? DecodeExpandableString(
+ bodySpan, out hasInterpolation, out invalidUnicodeAt)
+ : bodySpan.ToString();
+ if (quote == '"' && invalidUnicodeAt >= 0)
+ {
+ var sourcePosition = bodyStart + invalidUnicodeAt;
+ tokens.Add(InvalidUnicodeEscapeToken(src, sourcePosition));
+ return src.Length;
+ }
+
var end = k + 2; // past quote + '@'
tokens.Add(new PwshToken(
PwshTokenKind.QuotedString, body, null,
start, end - start, null)
- { IsHereString = true, IsSingleQuoted = quote == '\'' });
+ {
+ IsHereString = true,
+ IsSingleQuoted = quote == '\'',
+ HasInterpolation = hasInterpolation,
+ });
return end;
}
@@ -526,6 +552,163 @@ private static int TryReadHereString(
return src.Length;
}
+ private static string DecodeExpandableString(
+ ReadOnlySpan value, out bool hasInterpolation, out int invalidUnicodeAt)
+ {
+ var decoded = new StringBuilder(value.Length);
+ hasInterpolation = false;
+ invalidUnicodeAt = -1;
+ for (var i = 0; i < value.Length; i++)
+ {
+ if (value[i] == '`' && i + 1 < value.Length)
+ {
+ if (IsMalformedUnicodeEscape(value, i))
+ {
+ invalidUnicodeAt = i;
+ return decoded.ToString();
+ }
+
+ i = AppendBacktickEscape(value, i, decoded) - 1;
+ continue;
+ }
+
+ if (value[i] == '$' && StartsInterpolation(value, i))
+ {
+ hasInterpolation = true;
+ }
+
+ decoded.Append(value[i]);
+ }
+
+ return decoded.ToString();
+ }
+
+ internal static bool TryDecodeExpandableValue(
+ string value, out string decoded, out bool hasInterpolation)
+ {
+ decoded = DecodeExpandableString(
+ value.AsSpan(), out hasInterpolation, out var invalidUnicodeAt);
+ return invalidUnicodeAt < 0;
+ }
+
+ private static bool StartsInterpolation(ReadOnlySpan value, int dollarIndex)
+ {
+ if (dollarIndex + 1 >= value.Length)
+ {
+ return false;
+ }
+
+ var next = value[dollarIndex + 1];
+ return next is '(' or '{' or '?' or '^' or '$' or '_' or ':'
+ || char.IsLetterOrDigit(next);
+ }
+
+ private static int AppendBacktickEscape(
+ ReadOnlySpan value, int backtickIndex, StringBuilder target)
+ {
+ var escaped = value[backtickIndex + 1];
+ if (escaped == 'u' && TryReadUnicodeEscape(
+ value, backtickIndex, out var scalar, out var endIndex))
+ {
+ if (scalar <= char.MaxValue)
+ {
+ target.Append((char)scalar);
+ }
+ else
+ {
+ target.Append(char.ConvertFromUtf32(scalar));
+ }
+
+ return endIndex;
+ }
+
+ target.Append(escaped switch
+ {
+ 'n' => '\n',
+ 't' => '\t',
+ 'r' => '\r',
+ '0' => '\0',
+ 'a' => '\a',
+ 'b' => '\b',
+ 'e' => '\u001b',
+ 'f' => '\f',
+ 'v' => '\v',
+ _ => escaped,
+ });
+ return backtickIndex + 2;
+ }
+
+ private static bool TryReadUnicodeEscape(
+ ReadOnlySpan value, int backtickIndex, out int scalar, out int endIndex)
+ {
+ scalar = 0;
+ endIndex = backtickIndex + 2;
+ var openBrace = backtickIndex + 2;
+ if (openBrace >= value.Length || value[openBrace] != '{')
+ {
+ return false;
+ }
+
+ var i = openBrace + 1;
+ var digits = 0;
+ while (i < value.Length && digits < 6 && TryHexValue(value[i], out var hex))
+ {
+ scalar = (scalar * 16) + hex;
+ digits++;
+ i++;
+ }
+
+ if (digits == 0 || i >= value.Length || value[i] != '}'
+ || scalar > 0x10FFFF)
+ {
+ scalar = 0;
+ return false;
+ }
+
+ endIndex = i + 1;
+ return true;
+ }
+
+ private static bool IsMalformedUnicodeEscape(
+ ReadOnlySpan value, int backtickIndex)
+ {
+ return backtickIndex + 2 < value.Length
+ && value[backtickIndex + 1] == 'u'
+ && value[backtickIndex + 2] == '{'
+ && !TryReadUnicodeEscape(value, backtickIndex, out _, out _);
+ }
+
+ private static PwshToken InvalidUnicodeEscapeToken(
+ ReadOnlySpan source, int position) => new(
+ PwshTokenKind.UnparseableSentinel,
+ source.Slice(position).ToString(), null,
+ position, source.Length - position,
+ $"invalid PowerShell Unicode escape at position {position}");
+
+ private static bool TryHexValue(char value, out int hex)
+ {
+ if (value >= '0' && value <= '9')
+ {
+ hex = value - '0';
+ return true;
+ }
+
+ if (value >= 'a' && value <= 'f')
+ {
+ hex = value - 'a' + 10;
+ return true;
+ }
+
+ if (value >= 'A' && value <= 'F')
+ {
+ hex = value - 'A' + 10;
+ return true;
+ }
+
+ hex = 0;
+ return false;
+ }
+
// ---------------------------------------------------------------- regions
///
@@ -685,7 +868,12 @@ private static int ReadParameter(
if (i < src.Length && (src[i] == ':' || src[i] == '='))
{
i++;
- i = ScanWordRun(src, i);
+ i = ScanWordRun(src, i, out var invalidUnicodeAt);
+ if (invalidUnicodeAt >= 0)
+ {
+ tokens.Add(InvalidUnicodeEscapeToken(src, invalidUnicodeAt));
+ return src.Length;
+ }
}
tokens.Add(new PwshToken(
@@ -700,6 +888,7 @@ private static int ReadWord(
ReadOnlySpan src, int start, List tokens)
{
var sb = new StringBuilder();
+ var hasInterpolation = false;
var i = start;
while (i < src.Length)
{
@@ -720,14 +909,19 @@ private static int ReadWord(
break;
}
- var n = src[i + 1];
- if (n == '\n' || n == '\r')
+ var next = src[i + 1];
+ if (next == '\n' || next == '\r')
{
break; // line continuation — handled by the outer loop
}
- sb.Append(n);
- i += 2;
+ if (IsMalformedUnicodeEscape(src, i))
+ {
+ tokens.Add(InvalidUnicodeEscapeToken(src, i));
+ return src.Length;
+ }
+
+ i = AppendBacktickEscape(src, i, sb);
continue;
}
@@ -740,6 +934,7 @@ private static int ReadWord(
// ${name} is absorbed verbatim into the word.
if (c == '$' && i + 1 < src.Length && src[i + 1] == '{')
{
+ hasInterpolation = true;
var scan = OpaqueRegionScanner.Scan(
src, i + 1, '{', '}', OpaqueRegionScanner.PwshEscape);
if (!scan.Closed)
@@ -761,6 +956,11 @@ private static int ReadWord(
continue;
}
+ if (c == '$' && StartsInterpolation(src, i))
+ {
+ hasInterpolation = true;
+ }
+
sb.Append(c);
i++;
}
@@ -772,7 +972,8 @@ private static int ReadWord(
}
tokens.Add(new PwshToken(
- PwshTokenKind.Word, sb.ToString(), null, start, i - start, null));
+ PwshTokenKind.Word, sb.ToString(), null, start, i - start, null)
+ { HasInterpolation = hasInterpolation });
return i;
}
@@ -781,12 +982,15 @@ private static int ReadWord(
/// returning the index just past the run. Honors backtick escapes and
/// ${name} absorption; stops at a word boundary.
///
- private static int ScanWordRun(ReadOnlySpan src, int i)
+ private static int ScanWordRun(
+ ReadOnlySpan src, int i, out int invalidUnicodeAt)
{
+ invalidUnicodeAt = -1;
+ var start = i;
while (i < src.Length)
{
var c = src[i];
- if (IsWordBoundary(c))
+ if (IsWordBoundary(c) || (i == start && c == '#'))
{
break;
}
@@ -804,6 +1008,19 @@ private static int ScanWordRun(ReadOnlySpan src, int i)
break;
}
+ if (src[i + 1] == 'u' && TryReadUnicodeEscape(
+ src, i, out _, out var unicodeEnd))
+ {
+ i = unicodeEnd;
+ continue;
+ }
+
+ if (IsMalformedUnicodeEscape(src, i))
+ {
+ invalidUnicodeAt = i;
+ return src.Length;
+ }
+
i += 2;
continue;
}
@@ -851,7 +1068,7 @@ private static bool IsWordBoundary(char c)
case '>':
return true;
default:
- return false;
+ return c == '\0' || IsInlineWhitespace(c);
}
}
@@ -860,6 +1077,9 @@ private static bool IsWordBoundary(char c)
private static bool IsAsciiLetter(char c) =>
(c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z');
+ private static bool IsInlineWhitespace(char c) =>
+ c != '\r' && c != '\n' && char.IsWhiteSpace(c);
+
private static bool IsIdentifierStart(char c) =>
IsAsciiLetter(c) || c == '_';
diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Lexing/PwshToken.cs b/src/ShellSyntaxTree/Internal/Pwsh/Lexing/PwshToken.cs
index 2e78b63..623e525 100644
--- a/src/ShellSyntaxTree/Internal/Pwsh/Lexing/PwshToken.cs
+++ b/src/ShellSyntaxTree/Internal/Pwsh/Lexing/PwshToken.cs
@@ -52,6 +52,13 @@ internal readonly record struct PwshToken(
///
public bool IsHereString { get; init; }
+ ///
+ /// True when a word or expandable quoted string contains an unescaped
+ /// PowerShell interpolation marker. Literal strings always leave this
+ /// false.
+ ///
+ public bool HasInterpolation { get; init; }
+
///
/// True when this token contains
/// a newline and therefore acts as a statement separator equivalent to
diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshCommandParser.cs b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshCommandParser.cs
index 76d8f43..b2a0dd8 100644
--- a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshCommandParser.cs
+++ b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshCommandParser.cs
@@ -42,11 +42,14 @@ internal static ParsedCommand Parse(string source, PwshParserOptions options)
throw new ArgumentNullException(nameof(options));
}
- return ParseInternal(source, options, recursionDepth: 0, markWrapped: false);
+ return ParseInternal(
+ source, options, recursionDepth: 0, markWrapped: false,
+ sharedLocation: null);
}
private static ParsedCommand ParseInternal(
- string source, PwshParserOptions options, int recursionDepth, bool markWrapped)
+ string source, PwshParserOptions options, int recursionDepth, bool markWrapped,
+ PwshSetLocationContext? sharedLocation)
{
// §11 item 10: the size cap is checked before lexing, on the
// top-level input and on every decoded payload.
@@ -90,7 +93,7 @@ private static ParsedCommand ParseInternal(
}
var clauses = new List(segments.Count);
- var attribution = new PwshSetLocationContext();
+ var attribution = sharedLocation ?? new PwshSetLocationContext();
foreach (var segment in segments)
{
@@ -117,7 +120,7 @@ private static ParsedCommand ParseInternal(
var built = BuildSegment(
segment, source, options, effectiveOptions, workingDirectoryUnknown,
- recursionDepth, markWrapped);
+ recursionDepth, markWrapped, attribution);
if (built.Error is not null)
{
return Unparseable(source, built.Error);
@@ -127,9 +130,9 @@ private static ParsedCommand ParseInternal(
{
var clause = built.Clauses[k];
- // pwsh-recursion clauses already carry IsCommandStringWrapped;
- // they do not receive the outer attribution arg (a recursed
- // command runs in a fresh runspace).
+ // Expanded command-string clauses already carry wrapper and
+ // attribution state. Child pwsh uses an isolated context;
+ // Invoke-Expression shares and updates this one.
if (!built.IsRecursion)
{
clause = AttachAttributionArg(clause, attribution);
@@ -138,9 +141,9 @@ private static ParsedCommand ParseInternal(
clauses.Add(clause);
}
- // A Set-Location clause updates the attributed cwd for the
- // clauses that follow it (§9). Recursion expansion never carries
- // a Set-Location at the compound level.
+ // A directly built Set-Location clause updates the attributed cwd
+ // for clauses that follow it (§9). Expanded command strings have
+ // already updated the appropriate isolated or shared context.
if (!built.IsRecursion && built.Clauses.Count == 1)
{
UpdateAttribution(built.Clauses[0], options, attribution);
@@ -188,6 +191,11 @@ private static bool TryDetectAnomaly(IReadOnlyList tokens, out string
return true;
}
+ if (TryDetectUnsupportedInvocationShape(tokens, out reason))
+ {
+ return true;
+ }
+
// Item 4: a trailing '&' background-job operator.
if (TryDetectTrailingAmp(tokens, out reason))
{
@@ -204,6 +212,68 @@ private static bool TryDetectAnomaly(IReadOnlyList tokens, out string
return false;
}
+ private static bool TryDetectUnsupportedInvocationShape(
+ IReadOnlyList tokens, out string? reason)
+ {
+ var verbSlot = true;
+ foreach (var token in tokens)
+ {
+ if (token.Kind == PwshTokenKind.Whitespace)
+ {
+ verbSlot = true;
+ continue;
+ }
+
+ if (token.Kind == PwshTokenKind.Operator)
+ {
+ verbSlot = token.OperatorText is "&&" or "||" or ";" or "|" or "(" or "&";
+ continue;
+ }
+
+ if (verbSlot && token.Kind == PwshTokenKind.Word)
+ {
+ if (token.Value == ".")
+ {
+ reason = "the dot-source invocation operator is not supported in v0.2";
+ return true;
+ }
+
+ if (IsUnsupportedModuleQualifiedCmdlet(token.Value))
+ {
+ reason = $"module-qualified cmdlet '{token.Value}' is not supported in v0.2";
+ return true;
+ }
+ }
+
+ if (verbSlot && token.Kind == PwshTokenKind.QuotedString
+ && IsUnsupportedModuleQualifiedCmdlet(token.Value))
+ {
+ reason = $"module-qualified cmdlet '{token.Value}' is not supported in v0.2";
+ return true;
+ }
+
+ verbSlot = false;
+ }
+
+ reason = null;
+ return false;
+ }
+
+ private static bool IsUnsupportedModuleQualifiedCmdlet(string command)
+ {
+ if (string.Equals(
+ command,
+ "Microsoft.PowerShell.Utility\\Invoke-Expression",
+ StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ var separator = command.LastIndexOf('\\');
+ return separator > 0 && separator + 1 < command.Length
+ && PwshApprovedVerbs.IsCmdletShaped(command.Substring(separator + 1));
+ }
+
private static bool TryDetectKeywordAnomaly(IReadOnlyList tokens, out string? reason)
{
var verbSlot = true;
@@ -376,6 +446,7 @@ private static List SplitIntoSegments(
{
var segments = new List();
var depth = 0;
+ var expressionArgumentDepth = 0;
Segment current = new() { PrecedingOperator = CompoundOperator.None, Depth = 0 };
@@ -391,6 +462,24 @@ void Flush()
{
var t = tokens[i];
+ if (expressionArgumentDepth > 0)
+ {
+ current.Tokens.Add(t);
+ if (t.Kind == PwshTokenKind.Operator)
+ {
+ if (t.OperatorText == "(")
+ {
+ expressionArgumentDepth++;
+ }
+ else if (t.OperatorText == ")")
+ {
+ expressionArgumentDepth--;
+ }
+ }
+
+ continue;
+ }
+
if (t.Kind == PwshTokenKind.Whitespace)
{
// A retained whitespace token is a newline statement
@@ -411,6 +500,13 @@ void Flush()
var op = t.OperatorText;
if (op == "(")
{
+ if (StartsWithInvokeExpression(current.Tokens))
+ {
+ current.Tokens.Add(t);
+ expressionArgumentDepth = 1;
+ continue;
+ }
+
Flush();
depth++;
current = new Segment { PrecedingOperator = CompoundOperator.None, Depth = depth };
@@ -452,7 +548,7 @@ void Flush()
current.Tokens.Add(t);
}
- if (depth != 0)
+ if (depth != 0 || expressionArgumentDepth != 0)
{
error = $"unbalanced '(' grouping at position {source.Length}";
return segments;
@@ -472,6 +568,23 @@ void Flush()
_ => CompoundOperator.None,
};
+ private static bool StartsWithInvokeExpression(List tokens)
+ {
+ var verbIndex = tokens.Count > 0
+ && tokens[0].Kind == PwshTokenKind.Operator
+ && tokens[0].OperatorText == "&"
+ ? 1
+ : 0;
+ if (verbIndex >= tokens.Count
+ || tokens[verbIndex].Kind is not PwshTokenKind.Word
+ and not PwshTokenKind.QuotedString)
+ {
+ return false;
+ }
+
+ return IsInvokeExpressionName(tokens[verbIndex].Value);
+ }
+
// ---------------------------------------------------------------- segment build
private readonly struct BuildResult
@@ -501,7 +614,7 @@ public static BuildResult Fail(string? reason) =>
private static BuildResult BuildSegment(
Segment segment, string source, PwshParserOptions baseOptions,
PwshParserOptions effectiveOptions, bool workingDirectoryUnknown,
- int recursionDepth, bool markWrapped)
+ int recursionDepth, bool markWrapped, PwshSetLocationContext attribution)
{
var body = segment.Tokens;
var start = 0;
@@ -516,17 +629,32 @@ private static BuildResult BuildSegment(
{
// A lone call operator (`& ( ... )` — the group followed in a
// separate segment). Emit a dynamic, verb-less clause.
- return BuildResult.Ok(new Clause
+ var dynamicClause = AttachAttributionArg(new Clause
{
Operator = segment.PrecedingOperator,
Verb = new VerbChain { IsDynamic = true },
IsSubshell = segment.Depth > 0,
IsCommandStringWrapped = markWrapped,
- });
+ }, attribution);
+ attribution.SetDynamic();
+ return BuildResult.Recursion(new[] { dynamicClause });
+ }
+
+ if (start == 0 && body[start].Kind == PwshTokenKind.QuotedString)
+ {
+ return BuildResult.Fail(
+ "a quoted expression at command position is not supported in v0.2");
}
// Classify the command.
var classified = ClassifyVerb(body, start);
+ if (TryHandleInvokeExpression(
+ body, start, classified, source, baseOptions, recursionDepth,
+ segment, markWrapped, attribution, out var expressionResult))
+ {
+ return expressionResult;
+ }
+
if (classified.Kind == PwshCommandKind.PwshInvocation)
{
var recursion = TryRecurseIntoPwsh(
@@ -554,7 +682,7 @@ private static BuildResult BuildSegment(
IsDynamic = classified.IsDynamic,
};
- return BuildResult.Ok(new Clause
+ var clause = new Clause
{
Operator = segment.PrecedingOperator,
Verb = verb,
@@ -562,7 +690,16 @@ private static BuildResult BuildSegment(
Redirects = argResult.Redirects,
IsSubshell = segment.Depth > 0,
IsCommandStringWrapped = markWrapped,
- });
+ };
+
+ if (classified.IsDynamic)
+ {
+ clause = AttachAttributionArg(clause, attribution);
+ attribution.SetDynamic();
+ return BuildResult.Recursion(new[] { clause });
+ }
+
+ return BuildResult.Ok(clause);
}
// ---------------------------------------------------------------- verb chain
@@ -614,7 +751,7 @@ private static ClassifiedVerb ClassifyVerb(List body, int start)
// position is a dynamic command name (§3).
var isVariableWord = head.Kind == PwshTokenKind.Word
&& head.Value.Length > 0 && head.Value[0] == '$';
- if (isVariableWord
+ if (isVariableWord || head.HasInterpolation
|| head.Kind is PwshTokenKind.ScriptBlock or PwshTokenKind.Subexpression
or PwshTokenKind.Splat)
{
@@ -1065,6 +1202,199 @@ private static RedirectDirection MapRedirect(string op, out bool isMerge, out st
// ---------------------------------------------------------------- recursion
+ private static bool TryHandleInvokeExpression(
+ List body, int start, ClassifiedVerb verb, string source,
+ PwshParserOptions options, int recursionDepth, Segment segment, bool markWrapped,
+ PwshSetLocationContext attribution, out BuildResult result)
+ {
+ result = default;
+ if (!IsInvokeExpression(verb))
+ {
+ return false;
+ }
+
+ if (segment.PrecedingOperator == CompoundOperator.Pipe)
+ {
+ result = BuildResult.Fail(
+ "Invoke-Expression pipeline input is dynamic and cannot be parsed safely");
+ return true;
+ }
+
+ var payloadStart = start + 1;
+ string? inlinePayload = null;
+ if (payloadStart >= body.Count)
+ {
+ result = BuildResult.Fail("Invoke-Expression is missing its payload");
+ return true;
+ }
+
+ if (body[payloadStart].Kind == PwshTokenKind.Parameter)
+ {
+ var parameter = body[payloadStart].Value;
+ var colon = parameter.IndexOf(':');
+ var parameterName = colon >= 0
+ ? parameter.Substring(0, colon)
+ : parameter;
+ if (!string.Equals(
+ parameterName, "-Command", StringComparison.OrdinalIgnoreCase))
+ {
+ result = BuildResult.Fail(
+ "Invoke-Expression payload binding is ambiguous");
+ return true;
+ }
+
+ if (colon >= 0 && colon + 1 < parameter.Length)
+ {
+ if (payloadStart + 1 != body.Count)
+ {
+ result = BuildResult.Fail(
+ "Invoke-Expression payload binding is ambiguous");
+ return true;
+ }
+
+ inlinePayload = parameter.Substring(colon + 1);
+ }
+
+ payloadStart++;
+ if (inlinePayload is null && payloadStart >= body.Count)
+ {
+ result = BuildResult.Fail("Invoke-Expression is missing its payload");
+ return true;
+ }
+ }
+
+ for (var i = payloadStart; inlinePayload is null && i < body.Count; i++)
+ {
+ if (body[i].Kind == PwshTokenKind.Operator
+ && body[i].OperatorText is not "(" and not ")")
+ {
+ result = BuildResult.Fail(
+ "Invoke-Expression payload binding is ambiguous");
+ return true;
+ }
+ }
+
+ var payloadEnd = body.Count - 1;
+ var payloadValue = inlinePayload;
+ var rawPayload = inlinePayload;
+ var isStatic = false;
+ if (inlinePayload is not null)
+ {
+ if (!PwshLexer.TryDecodeExpandableValue(
+ inlinePayload!, out var decodedPayload, out var hasInterpolation))
+ {
+ result = BuildResult.Fail("invalid PowerShell Unicode escape in Invoke-Expression payload");
+ return true;
+ }
+
+ payloadValue = decodedPayload;
+ isStatic = !hasInterpolation
+ && !PwshResolver.LooksLikeCommaArray(payloadValue);
+ }
+
+ if (inlinePayload is null)
+ {
+ var singlePayload = payloadStart == payloadEnd;
+ var payload = body[payloadStart];
+ payloadValue = payload.Value;
+ rawPayload = SourceSlice(source, body[payloadStart], body[payloadEnd]);
+ isStatic = singlePayload
+ && payload.Kind is PwshTokenKind.Word or PwshTokenKind.QuotedString
+ && !payload.HasInterpolation
+ && (payload.Kind != PwshTokenKind.Word
+ || !PwshResolver.LooksLikeCommaArray(payload.Value));
+ }
+
+ if (!isStatic)
+ {
+ var canonicalVerb = verb.CanonicalVerb;
+ if (canonicalVerb is null && verb.VerbTokens.Count > 0
+ && string.Equals(
+ verb.VerbTokens[0], "iex", StringComparison.OrdinalIgnoreCase))
+ {
+ canonicalVerb = "Invoke-Expression";
+ }
+
+ var dynamicClause = new Clause
+ {
+ Operator = segment.PrecedingOperator,
+ Verb = new VerbChain
+ {
+ Tokens = verb.VerbTokens,
+ CanonicalVerb = canonicalVerb,
+ IsDynamic = verb.IsDynamic,
+ },
+ Args = new[]
+ {
+ new Arg
+ {
+ Raw = rawPayload!,
+ Kind = ArgKind.DynamicSkip,
+ IsPath = false,
+ },
+ },
+ IsSubshell = segment.Depth > 0,
+ IsCommandStringWrapped = markWrapped,
+ };
+
+ // Runtime code can call Set-Location in the current scope. Once
+ // the payload is dynamic, every following relative path must
+ // safe-fail rather than retain the previously known location.
+ dynamicClause = AttachAttributionArg(dynamicClause, attribution);
+ attribution.SetDynamic();
+ result = BuildResult.Recursion(new[] { dynamicClause });
+ return true;
+ }
+
+ if (recursionDepth + 1 > MaxRecursionDepth)
+ {
+ result = BuildResult.Fail(
+ "PowerShell command-string recursion depth exceeded (>5)");
+ return true;
+ }
+
+ var innerParsed = ParseInternal(
+ payloadValue!, options, recursionDepth + 1, markWrapped: true,
+ sharedLocation: attribution);
+ if (innerParsed.IsUnparseable)
+ {
+ result = BuildResult.Fail(innerParsed.UnparseableReason);
+ return true;
+ }
+
+ var expanded = new List(innerParsed.Clauses.Count);
+ for (var i = 0; i < innerParsed.Clauses.Count; i++)
+ {
+ var innerClause = innerParsed.Clauses[i];
+ expanded.Add(innerClause with
+ {
+ Operator = i == 0 ? segment.PrecedingOperator : innerClause.Operator,
+ IsSubshell = segment.Depth > 0 || innerClause.IsSubshell,
+ IsCommandStringWrapped = true,
+ });
+ }
+
+ result = BuildResult.Recursion(expanded);
+ return true;
+ }
+
+ private static bool IsInvokeExpression(ClassifiedVerb verb)
+ {
+ var identity = verb.CanonicalVerb
+ ?? (verb.VerbTokens.Count > 0 ? verb.VerbTokens[0] : null);
+ return identity is not null && IsInvokeExpressionName(identity);
+ }
+
+ private static bool IsInvokeExpressionName(string identity)
+ {
+ return string.Equals(identity, "Invoke-Expression", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(identity, "iex", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(
+ identity,
+ "Microsoft.PowerShell.Utility\\Invoke-Expression",
+ StringComparison.OrdinalIgnoreCase);
+ }
+
private static bool TryRecurseIntoPwsh(
List body, int start, ClassifiedVerb verb, string source,
PwshParserOptions options, int recursionDepth, Segment segment, bool markWrapped,
@@ -1134,7 +1464,8 @@ private static bool TryRecurseIntoPwsh(
}
var innerParsed = ParseInternal(
- inner, options, recursionDepth + 1, markWrapped: true);
+ inner, options, recursionDepth + 1, markWrapped: true,
+ sharedLocation: null);
if (innerParsed.IsUnparseable)
{
result = BuildResult.Fail(innerParsed.UnparseableReason);
@@ -1382,4 +1713,21 @@ private static string SourceSlice(string source, PwshToken token)
return source.Substring(token.SourceStart, len);
}
+
+ private static string SourceSlice(string source, PwshToken first, PwshToken last)
+ {
+ var start = first.SourceStart;
+ var end = last.SourceStart + last.SourceLength;
+ if (start < 0 || start >= source.Length || end <= start)
+ {
+ return first.Value;
+ }
+
+ if (end > source.Length)
+ {
+ end = source.Length;
+ }
+
+ return source.Substring(start, end - start);
+ }
}
diff --git a/src/ShellSyntaxTree/VerbChain.cs b/src/ShellSyntaxTree/VerbChain.cs
index 3532b5a..6333e36 100644
--- a/src/ShellSyntaxTree/VerbChain.cs
+++ b/src/ShellSyntaxTree/VerbChain.cs
@@ -41,8 +41,9 @@ public sealed record VerbChain
///
/// True when the clause's command name is a dynamic token the parser
- /// cannot statically identify — a variable (& $exe), a
- /// subexpression (& (Get-Thing)), or a script block
+ /// cannot statically identify — a variable (& $exe), an
+ /// interpolated name (& "tool-$name"), a subexpression
+ /// (& (Get-Thing)), or a script block
/// (& { ... }) at verb position. still
/// carries the verbatim token; is null.
///
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/157_recursion_iex_not_recursed.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/157_recursion_iex_static.json
similarity index 50%
rename from tests/ShellSyntaxTree.Tests/Corpus/powershell/157_recursion_iex_not_recursed.json
rename to tests/ShellSyntaxTree.Tests/Corpus/powershell/157_recursion_iex_static.json
index c4e91b7..24bf3f2 100644
--- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/157_recursion_iex_not_recursed.json
+++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/157_recursion_iex_static.json
@@ -1,5 +1,5 @@
{
- "name": "Recursion iex not recursed",
+ "name": "Recursion iex static",
"input": "iex \u0022Remove-Item C:\\x\u0022",
"expected": {
"isUnparseable": false,
@@ -7,20 +7,20 @@
{
"operator": "None",
"verb": [
- "iex"
+ "Remove-Item"
],
- "canonicalVerb": "Invoke-Expression",
"args": [
{
- "raw": "\u0022Remove-Item C:\\x\u0022",
+ "raw": "C:\\x",
"kind": "Literal",
"isPath": true,
- "resolved": "C:/work/Remove-Item C:/x"
+ "resolved": "C:/x"
}
],
- "redirects": []
+ "redirects": [],
+ "isCommandStringWrapped": true
}
]
},
- "notes": "Invoke-Expression / iex is never recursed into (\u00A710)."
+ "notes": "A static iex payload surfaces its inner Remove-Item clause (\u00A710)."
}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/220_iex_full_name_static.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/220_iex_full_name_static.json
new file mode 100644
index 0000000..0a20411
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/220_iex_full_name_static.json
@@ -0,0 +1,19 @@
+{
+ "name": "Iex full name static",
+ "input": "Invoke-Expression \u0027Get-Date\u0027",
+ "expected": {
+ "isUnparseable": false,
+ "clauses": [
+ {
+ "operator": "None",
+ "verb": [
+ "Get-Date"
+ ],
+ "args": [],
+ "redirects": [],
+ "isCommandStringWrapped": true
+ }
+ ]
+ },
+ "notes": "The full cmdlet name recurses into one static literal payload."
+}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/221_iex_command_parameter_static.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/221_iex_command_parameter_static.json
new file mode 100644
index 0000000..9d296b0
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/221_iex_command_parameter_static.json
@@ -0,0 +1,19 @@
+{
+ "name": "Iex command parameter static",
+ "input": "Invoke-Expression -Command \u0027Get-Process\u0027",
+ "expected": {
+ "isUnparseable": false,
+ "clauses": [
+ {
+ "operator": "None",
+ "verb": [
+ "Get-Process"
+ ],
+ "args": [],
+ "redirects": [],
+ "isCommandStringWrapped": true
+ }
+ ]
+ },
+ "notes": "The exact -Command parameter binds one static payload."
+}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/222_iex_variable_dynamic.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/222_iex_variable_dynamic.json
new file mode 100644
index 0000000..d1f8941
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/222_iex_variable_dynamic.json
@@ -0,0 +1,24 @@
+{
+ "name": "Iex variable dynamic",
+ "input": "Invoke-Expression $code",
+ "expected": {
+ "isUnparseable": false,
+ "clauses": [
+ {
+ "operator": "None",
+ "verb": [
+ "Invoke-Expression"
+ ],
+ "args": [
+ {
+ "raw": "$code",
+ "kind": "DynamicSkip",
+ "isPath": false
+ }
+ ],
+ "redirects": []
+ }
+ ]
+ },
+ "notes": "A variable payload remains an Invoke-Expression clause with one DynamicSkip arg."
+}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/223_iex_interpolated_dynamic.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/223_iex_interpolated_dynamic.json
new file mode 100644
index 0000000..a096f2d
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/223_iex_interpolated_dynamic.json
@@ -0,0 +1,25 @@
+{
+ "name": "Iex interpolated dynamic",
+ "input": "iex \u0022Remove-$noun C:\\x\u0022",
+ "expected": {
+ "isUnparseable": false,
+ "clauses": [
+ {
+ "operator": "None",
+ "verb": [
+ "iex"
+ ],
+ "canonicalVerb": "Invoke-Expression",
+ "args": [
+ {
+ "raw": "\u0022Remove-$noun C:\\x\u0022",
+ "kind": "DynamicSkip",
+ "isPath": false
+ }
+ ],
+ "redirects": []
+ }
+ ]
+ },
+ "notes": "An interpolated payload remains opaque and dynamic."
+}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/224_iex_concatenated_dynamic.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/224_iex_concatenated_dynamic.json
new file mode 100644
index 0000000..257bb98
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/224_iex_concatenated_dynamic.json
@@ -0,0 +1,25 @@
+{
+ "name": "Iex concatenated dynamic",
+ "input": "iex (\u0027Get-\u0027 \u002B \u0027Date\u0027)",
+ "expected": {
+ "isUnparseable": false,
+ "clauses": [
+ {
+ "operator": "None",
+ "verb": [
+ "iex"
+ ],
+ "canonicalVerb": "Invoke-Expression",
+ "args": [
+ {
+ "raw": "(\u0027Get-\u0027 \u002B \u0027Date\u0027)",
+ "kind": "DynamicSkip",
+ "isPath": false
+ }
+ ],
+ "redirects": []
+ }
+ ]
+ },
+ "notes": "Literal concatenation is not evaluated and collapses to one DynamicSkip arg."
+}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/225_iex_pipeline_dynamic.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/225_iex_pipeline_dynamic.json
new file mode 100644
index 0000000..f283e44
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/225_iex_pipeline_dynamic.json
@@ -0,0 +1,10 @@
+{
+ "name": "Iex pipeline dynamic",
+ "input": "Get-Content script.ps1 | Invoke-Expression",
+ "expected": {
+ "isUnparseable": true,
+ "unparseableReasonContains": "Invoke-Expression pipeline input is dynamic and cannot be parsed safely"
+ },
+ "notes": "Pipeline-fed expression code is valid PowerShell but safe-fails as unparseable.",
+ "oracleExpectation": "OutOfScope"
+}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/226_iex_inherits_location.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/226_iex_inherits_location.json
new file mode 100644
index 0000000..6a525f7
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/226_iex_inherits_location.json
@@ -0,0 +1,48 @@
+{
+ "name": "Iex inherits location",
+ "input": "Set-Location C:\\a; iex \u0027Remove-Item child.txt\u0027",
+ "expected": {
+ "isUnparseable": false,
+ "clauses": [
+ {
+ "operator": "None",
+ "verb": [
+ "Set-Location"
+ ],
+ "args": [
+ {
+ "raw": "C:\\a",
+ "kind": "Literal",
+ "isPath": true,
+ "resolved": "C:/a"
+ }
+ ],
+ "redirects": []
+ },
+ {
+ "operator": "Sequence",
+ "verb": [
+ "Remove-Item"
+ ],
+ "args": [
+ {
+ "raw": "child.txt",
+ "kind": "Literal",
+ "isPath": true,
+ "resolved": "C:/a/child.txt"
+ },
+ {
+ "raw": "C:/a",
+ "kind": "Literal",
+ "isPath": true,
+ "resolved": "C:/a",
+ "isCwdAttribution": true
+ }
+ ],
+ "redirects": [],
+ "isCommandStringWrapped": true
+ }
+ ]
+ },
+ "notes": "A static payload inherits the caller\u0027s effective location."
+}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/227_iex_exports_location.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/227_iex_exports_location.json
new file mode 100644
index 0000000..a55db47
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/227_iex_exports_location.json
@@ -0,0 +1,48 @@
+{
+ "name": "Iex exports location",
+ "input": "iex \u0027Set-Location C:\\b\u0027; Remove-Item child.txt",
+ "expected": {
+ "isUnparseable": false,
+ "clauses": [
+ {
+ "operator": "None",
+ "verb": [
+ "Set-Location"
+ ],
+ "args": [
+ {
+ "raw": "C:\\b",
+ "kind": "Literal",
+ "isPath": true,
+ "resolved": "C:/b"
+ }
+ ],
+ "redirects": [],
+ "isCommandStringWrapped": true
+ },
+ {
+ "operator": "Sequence",
+ "verb": [
+ "Remove-Item"
+ ],
+ "args": [
+ {
+ "raw": "child.txt",
+ "kind": "Literal",
+ "isPath": true,
+ "resolved": "C:/b/child.txt"
+ },
+ {
+ "raw": "C:/b",
+ "kind": "Literal",
+ "isPath": true,
+ "resolved": "C:/b",
+ "isCwdAttribution": true
+ }
+ ],
+ "redirects": []
+ }
+ ]
+ },
+ "notes": "A location change inside iex affects following outer clauses."
+}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/228_iex_recursion_depth_overflow.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/228_iex_recursion_depth_overflow.json
new file mode 100644
index 0000000..48dd2bc
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/228_iex_recursion_depth_overflow.json
@@ -0,0 +1,10 @@
+{
+ "name": "Iex recursion depth overflow",
+ "input": "iex \u0027iex \u0027\u0027iex \u0027\u0027\u0027\u0027iex \u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027iex \u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027iex \u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027Get-Date\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027\u0027",
+ "expected": {
+ "isUnparseable": true,
+ "unparseableReasonContains": "PowerShell command-string recursion depth exceeded"
+ },
+ "notes": "Six nested static expression strings exceed the shared depth-five cap.",
+ "oracleExpectation": "OutOfScope"
+}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/229_iex_quoted_alias_dynamic.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/229_iex_quoted_alias_dynamic.json
new file mode 100644
index 0000000..422b5c3
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/229_iex_quoted_alias_dynamic.json
@@ -0,0 +1,25 @@
+{
+ "name": "Iex quoted alias dynamic",
+ "input": "\u0026 \u0027iex\u0027 $code",
+ "expected": {
+ "isUnparseable": false,
+ "clauses": [
+ {
+ "operator": "None",
+ "verb": [
+ "iex"
+ ],
+ "canonicalVerb": "Invoke-Expression",
+ "args": [
+ {
+ "raw": "$code",
+ "kind": "DynamicSkip",
+ "isPath": false
+ }
+ ],
+ "redirects": []
+ }
+ ]
+ },
+ "notes": "A quoted alias invoked through the call operator still safe-fails its payload."
+}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/230_iex_module_qualified_dynamic.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/230_iex_module_qualified_dynamic.json
new file mode 100644
index 0000000..4018fff
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/230_iex_module_qualified_dynamic.json
@@ -0,0 +1,24 @@
+{
+ "name": "Iex module qualified dynamic",
+ "input": "Microsoft.PowerShell.Utility\\Invoke-Expression $code",
+ "expected": {
+ "isUnparseable": false,
+ "clauses": [
+ {
+ "operator": "None",
+ "verb": [
+ "Microsoft.PowerShell.Utility\\Invoke-Expression"
+ ],
+ "args": [
+ {
+ "raw": "$code",
+ "kind": "DynamicSkip",
+ "isPath": false
+ }
+ ],
+ "redirects": []
+ }
+ ]
+ },
+ "notes": "A module-qualified cmdlet name still receives expression security handling."
+}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/231_iex_unicode_interpolation_dynamic.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/231_iex_unicode_interpolation_dynamic.json
new file mode 100644
index 0000000..eb0e714
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/231_iex_unicode_interpolation_dynamic.json
@@ -0,0 +1,25 @@
+{
+ "name": "Iex unicode interpolation dynamic",
+ "input": "iex \u0022Remove-$\u00E9 C:\\x\u0022",
+ "expected": {
+ "isUnparseable": false,
+ "clauses": [
+ {
+ "operator": "None",
+ "verb": [
+ "iex"
+ ],
+ "canonicalVerb": "Invoke-Expression",
+ "args": [
+ {
+ "raw": "\u0022Remove-$\u00E9 C:\\x\u0022",
+ "kind": "DynamicSkip",
+ "isPath": false
+ }
+ ],
+ "redirects": []
+ }
+ ]
+ },
+ "notes": "Unicode variable interpolation cannot hide a clean inner verb."
+}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/232_iex_comma_array_dynamic.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/232_iex_comma_array_dynamic.json
new file mode 100644
index 0000000..bd4d667
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/232_iex_comma_array_dynamic.json
@@ -0,0 +1,24 @@
+{
+ "name": "Iex comma array dynamic",
+ "input": "Invoke-Expression Write-Output,OTHER",
+ "expected": {
+ "isUnparseable": false,
+ "clauses": [
+ {
+ "operator": "None",
+ "verb": [
+ "Invoke-Expression"
+ ],
+ "args": [
+ {
+ "raw": "Write-Output,OTHER",
+ "kind": "DynamicSkip",
+ "isPath": false
+ }
+ ],
+ "redirects": []
+ }
+ ]
+ },
+ "notes": "An unquoted comma array is computed rather than one static scalar string."
+}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/233_iex_dynamic_location.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/233_iex_dynamic_location.json
new file mode 100644
index 0000000..96fad19
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/233_iex_dynamic_location.json
@@ -0,0 +1,67 @@
+{
+ "name": "Iex dynamic location",
+ "input": "Set-Location C:\\safe; iex $code; Remove-Item child.txt",
+ "expected": {
+ "isUnparseable": false,
+ "clauses": [
+ {
+ "operator": "None",
+ "verb": [
+ "Set-Location"
+ ],
+ "args": [
+ {
+ "raw": "C:\\safe",
+ "kind": "Literal",
+ "isPath": true,
+ "resolved": "C:/safe"
+ }
+ ],
+ "redirects": []
+ },
+ {
+ "operator": "Sequence",
+ "verb": [
+ "iex"
+ ],
+ "canonicalVerb": "Invoke-Expression",
+ "args": [
+ {
+ "raw": "$code",
+ "kind": "DynamicSkip",
+ "isPath": false
+ },
+ {
+ "raw": "C:/safe",
+ "kind": "Literal",
+ "isPath": true,
+ "resolved": "C:/safe",
+ "isCwdAttribution": true
+ }
+ ],
+ "redirects": []
+ },
+ {
+ "operator": "Sequence",
+ "verb": [
+ "Remove-Item"
+ ],
+ "args": [
+ {
+ "raw": "child.txt",
+ "kind": "DynamicSkip",
+ "isPath": false
+ },
+ {
+ "raw": "\u003Cdynamic-cwd\u003E",
+ "kind": "DynamicSkip",
+ "isPath": false,
+ "isCwdAttribution": true
+ }
+ ],
+ "redirects": []
+ }
+ ]
+ },
+ "notes": "Dynamic current-scope code invalidates location attribution for following paths."
+}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/234_dynamic_interpolated_iex_name.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/234_dynamic_interpolated_iex_name.json
new file mode 100644
index 0000000..e1f6833
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/234_dynamic_interpolated_iex_name.json
@@ -0,0 +1,25 @@
+{
+ "name": "Dynamic interpolated iex name",
+ "input": "\u0026 \u0022i$part\u0022 $code",
+ "expected": {
+ "isUnparseable": false,
+ "clauses": [
+ {
+ "operator": "None",
+ "verb": [
+ "i$part"
+ ],
+ "isDynamic": true,
+ "args": [
+ {
+ "raw": "$code",
+ "kind": "EnvVar",
+ "isPath": false
+ }
+ ],
+ "redirects": []
+ }
+ ]
+ },
+ "notes": "An interpolated call-operator command name is dynamic and cannot bypass iex handling."
+}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/235_iex_unicode_escaped_alias.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/235_iex_unicode_escaped_alias.json
new file mode 100644
index 0000000..3dc358e
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/235_iex_unicode_escaped_alias.json
@@ -0,0 +1,25 @@
+{
+ "name": "Iex unicode escaped alias",
+ "input": "\u0026 \u0022i\u0060u{65}x\u0022 $code",
+ "expected": {
+ "isUnparseable": false,
+ "clauses": [
+ {
+ "operator": "None",
+ "verb": [
+ "iex"
+ ],
+ "canonicalVerb": "Invoke-Expression",
+ "args": [
+ {
+ "raw": "$code",
+ "kind": "DynamicSkip",
+ "isPath": false
+ }
+ ],
+ "redirects": []
+ }
+ ]
+ },
+ "notes": "A Unicode-escaped iex name is decoded before command identity checks."
+}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/236_iex_bare_backtick_newline.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/236_iex_bare_backtick_newline.json
new file mode 100644
index 0000000..6066b47
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/236_iex_bare_backtick_newline.json
@@ -0,0 +1,34 @@
+{
+ "name": "Iex bare backtick newline",
+ "input": "iex Write-Output\u0060 harmless\u0060nGet-Date",
+ "expected": {
+ "isUnparseable": false,
+ "clauses": [
+ {
+ "operator": "None",
+ "verb": [
+ "Write-Output"
+ ],
+ "args": [
+ {
+ "raw": "harmless",
+ "kind": "Literal",
+ "isPath": false
+ }
+ ],
+ "redirects": [],
+ "isCommandStringWrapped": true
+ },
+ {
+ "operator": "Sequence",
+ "verb": [
+ "Get-Date"
+ ],
+ "args": [],
+ "redirects": [],
+ "isCommandStringWrapped": true
+ }
+ ]
+ },
+ "notes": "A backtick newline escape in a bare static payload surfaces both commands."
+}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/237_iex_herestring_backtick_newline.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/237_iex_herestring_backtick_newline.json
new file mode 100644
index 0000000..4736e90
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/237_iex_herestring_backtick_newline.json
@@ -0,0 +1,34 @@
+{
+ "name": "Iex herestring backtick newline",
+ "input": "iex @\u0022\nWrite-Output ok\u0060nGet-Date\n\u0022@",
+ "expected": {
+ "isUnparseable": false,
+ "clauses": [
+ {
+ "operator": "None",
+ "verb": [
+ "Write-Output"
+ ],
+ "args": [
+ {
+ "raw": "ok",
+ "kind": "Literal",
+ "isPath": false
+ }
+ ],
+ "redirects": [],
+ "isCommandStringWrapped": true
+ },
+ {
+ "operator": "Sequence",
+ "verb": [
+ "Get-Date"
+ ],
+ "args": [],
+ "redirects": [],
+ "isCommandStringWrapped": true
+ }
+ ]
+ },
+ "notes": "An expandable here-string decodes its backtick newline before recursion."
+}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/238_iex_backtick_location.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/238_iex_backtick_location.json
new file mode 100644
index 0000000..4cdc23c
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/238_iex_backtick_location.json
@@ -0,0 +1,92 @@
+{
+ "name": "Iex backtick location",
+ "input": "Set-Location C:\\safe; iex Write-Output\u0060 ok\u0060nSet-Location\u0060 C:\\evil; Remove-Item child.txt",
+ "expected": {
+ "isUnparseable": false,
+ "clauses": [
+ {
+ "operator": "None",
+ "verb": [
+ "Set-Location"
+ ],
+ "args": [
+ {
+ "raw": "C:\\safe",
+ "kind": "Literal",
+ "isPath": true,
+ "resolved": "C:/safe"
+ }
+ ],
+ "redirects": []
+ },
+ {
+ "operator": "Sequence",
+ "verb": [
+ "Write-Output"
+ ],
+ "args": [
+ {
+ "raw": "ok",
+ "kind": "Literal",
+ "isPath": false
+ },
+ {
+ "raw": "C:/safe",
+ "kind": "Literal",
+ "isPath": true,
+ "resolved": "C:/safe",
+ "isCwdAttribution": true
+ }
+ ],
+ "redirects": [],
+ "isCommandStringWrapped": true
+ },
+ {
+ "operator": "Sequence",
+ "verb": [
+ "Set-Location"
+ ],
+ "args": [
+ {
+ "raw": "C:\\evil",
+ "kind": "Literal",
+ "isPath": true,
+ "resolved": "C:/evil"
+ },
+ {
+ "raw": "C:/safe",
+ "kind": "Literal",
+ "isPath": true,
+ "resolved": "C:/safe",
+ "isCwdAttribution": true
+ }
+ ],
+ "redirects": [],
+ "isCommandStringWrapped": true
+ },
+ {
+ "operator": "Sequence",
+ "verb": [
+ "Remove-Item"
+ ],
+ "args": [
+ {
+ "raw": "child.txt",
+ "kind": "Literal",
+ "isPath": true,
+ "resolved": "C:/evil/child.txt"
+ },
+ {
+ "raw": "C:/evil",
+ "kind": "Literal",
+ "isPath": true,
+ "resolved": "C:/evil",
+ "isCwdAttribution": true
+ }
+ ],
+ "redirects": []
+ }
+ ]
+ },
+ "notes": "A hidden escaped-newline location change is surfaced and propagated."
+}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/239_iex_command_colon_inline.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/239_iex_command_colon_inline.json
new file mode 100644
index 0000000..8681117
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/239_iex_command_colon_inline.json
@@ -0,0 +1,19 @@
+{
+ "name": "Iex command colon inline",
+ "input": "iex -Command:Get-Date",
+ "expected": {
+ "isUnparseable": false,
+ "clauses": [
+ {
+ "operator": "None",
+ "verb": [
+ "Get-Date"
+ ],
+ "args": [],
+ "redirects": [],
+ "isCommandStringWrapped": true
+ }
+ ]
+ },
+ "notes": "The exact colon-form -Command parameter binds an inline static payload."
+}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/240_iex_command_colon_quoted.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/240_iex_command_colon_quoted.json
new file mode 100644
index 0000000..5044037
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/240_iex_command_colon_quoted.json
@@ -0,0 +1,19 @@
+{
+ "name": "Iex command colon quoted",
+ "input": "iex -Command:\u0027Get-Date\u0027",
+ "expected": {
+ "isUnparseable": false,
+ "clauses": [
+ {
+ "operator": "None",
+ "verb": [
+ "Get-Date"
+ ],
+ "args": [],
+ "redirects": [],
+ "isCommandStringWrapped": true
+ }
+ ]
+ },
+ "notes": "An empty colon-form parameter tail binds the following quoted payload."
+}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/241_iex_command_colon_dynamic.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/241_iex_command_colon_dynamic.json
new file mode 100644
index 0000000..b77d615
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/241_iex_command_colon_dynamic.json
@@ -0,0 +1,25 @@
+{
+ "name": "Iex command colon dynamic",
+ "input": "iex -Command:$code",
+ "expected": {
+ "isUnparseable": false,
+ "clauses": [
+ {
+ "operator": "None",
+ "verb": [
+ "iex"
+ ],
+ "canonicalVerb": "Invoke-Expression",
+ "args": [
+ {
+ "raw": "$code",
+ "kind": "DynamicSkip",
+ "isPath": false
+ }
+ ],
+ "redirects": []
+ }
+ ]
+ },
+ "notes": "A dynamic inline colon-form payload remains one DynamicSkip arg."
+}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/242_iex_vertical_tab_path.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/242_iex_vertical_tab_path.json
new file mode 100644
index 0000000..53fe564
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/242_iex_vertical_tab_path.json
@@ -0,0 +1,26 @@
+{
+ "name": "Iex vertical tab path",
+ "input": "iex \u0022Remove-Item\u0060vC:\\x\u0022",
+ "expected": {
+ "isUnparseable": false,
+ "clauses": [
+ {
+ "operator": "None",
+ "verb": [
+ "Remove-Item"
+ ],
+ "args": [
+ {
+ "raw": "C:\\x",
+ "kind": "Literal",
+ "isPath": true,
+ "resolved": "C:/x"
+ }
+ ],
+ "redirects": [],
+ "isCommandStringWrapped": true
+ }
+ ]
+ },
+ "notes": "Decoded vertical-tab whitespace separates the inner path argument."
+}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/243_iex_form_feed_path.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/243_iex_form_feed_path.json
new file mode 100644
index 0000000..9437904
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/243_iex_form_feed_path.json
@@ -0,0 +1,26 @@
+{
+ "name": "Iex form feed path",
+ "input": "iex \u0022Remove-Item\u0060fC:\\x\u0022",
+ "expected": {
+ "isUnparseable": false,
+ "clauses": [
+ {
+ "operator": "None",
+ "verb": [
+ "Remove-Item"
+ ],
+ "args": [
+ {
+ "raw": "C:\\x",
+ "kind": "Literal",
+ "isPath": true,
+ "resolved": "C:/x"
+ }
+ ],
+ "redirects": [],
+ "isCommandStringWrapped": true
+ }
+ ]
+ },
+ "notes": "Decoded form-feed whitespace separates the inner path argument."
+}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/244_iex_unicode_whitespace_path.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/244_iex_unicode_whitespace_path.json
new file mode 100644
index 0000000..79077c8
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/244_iex_unicode_whitespace_path.json
@@ -0,0 +1,26 @@
+{
+ "name": "Iex unicode whitespace path",
+ "input": "iex \u0022Remove-Item\u0060u{2003}C:\\x\u0022",
+ "expected": {
+ "isUnparseable": false,
+ "clauses": [
+ {
+ "operator": "None",
+ "verb": [
+ "Remove-Item"
+ ],
+ "args": [
+ {
+ "raw": "C:\\x",
+ "kind": "Literal",
+ "isPath": true,
+ "resolved": "C:/x"
+ }
+ ],
+ "redirects": [],
+ "isCommandStringWrapped": true
+ }
+ ]
+ },
+ "notes": "Decoded Unicode whitespace separates the inner path argument."
+}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/245_iex_vertical_tab_location.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/245_iex_vertical_tab_location.json
new file mode 100644
index 0000000..f3fc33e
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/245_iex_vertical_tab_location.json
@@ -0,0 +1,70 @@
+{
+ "name": "Iex vertical tab location",
+ "input": "Set-Location C:\\safe; iex \u0022Set-Location\u0060vC:\\evil\u0022; Remove-Item child.txt",
+ "expected": {
+ "isUnparseable": false,
+ "clauses": [
+ {
+ "operator": "None",
+ "verb": [
+ "Set-Location"
+ ],
+ "args": [
+ {
+ "raw": "C:\\safe",
+ "kind": "Literal",
+ "isPath": true,
+ "resolved": "C:/safe"
+ }
+ ],
+ "redirects": []
+ },
+ {
+ "operator": "Sequence",
+ "verb": [
+ "Set-Location"
+ ],
+ "args": [
+ {
+ "raw": "C:\\evil",
+ "kind": "Literal",
+ "isPath": true,
+ "resolved": "C:/evil"
+ },
+ {
+ "raw": "C:/safe",
+ "kind": "Literal",
+ "isPath": true,
+ "resolved": "C:/safe",
+ "isCwdAttribution": true
+ }
+ ],
+ "redirects": [],
+ "isCommandStringWrapped": true
+ },
+ {
+ "operator": "Sequence",
+ "verb": [
+ "Remove-Item"
+ ],
+ "args": [
+ {
+ "raw": "child.txt",
+ "kind": "Literal",
+ "isPath": true,
+ "resolved": "C:/evil/child.txt"
+ },
+ {
+ "raw": "C:/evil",
+ "kind": "Literal",
+ "isPath": true,
+ "resolved": "C:/evil",
+ "isCwdAttribution": true
+ }
+ ],
+ "redirects": []
+ }
+ ]
+ },
+ "notes": "Decoded vertical-tab whitespace preserves an inner location change."
+}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/246_iex_command_colon_comment.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/246_iex_command_colon_comment.json
new file mode 100644
index 0000000..f684381
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/246_iex_command_colon_comment.json
@@ -0,0 +1,9 @@
+{
+ "name": "Iex command colon comment",
+ "input": "iex -Command:#comment",
+ "expected": {
+ "isUnparseable": true,
+ "unparseableReasonContains": "Invoke-Expression is missing its payload"
+ },
+ "notes": "A comment after an empty colon value leaves -Command without a payload."
+}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/247_iex_command_colon_backtick_newline.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/247_iex_command_colon_backtick_newline.json
new file mode 100644
index 0000000..de95a0f
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/247_iex_command_colon_backtick_newline.json
@@ -0,0 +1,28 @@
+{
+ "name": "Iex command colon backtick newline",
+ "input": "iex -Command:Write-Output\u0060nGet-Date",
+ "expected": {
+ "isUnparseable": false,
+ "clauses": [
+ {
+ "operator": "None",
+ "verb": [
+ "Write-Output"
+ ],
+ "args": [],
+ "redirects": [],
+ "isCommandStringWrapped": true
+ },
+ {
+ "operator": "Sequence",
+ "verb": [
+ "Get-Date"
+ ],
+ "args": [],
+ "redirects": [],
+ "isCommandStringWrapped": true
+ }
+ ]
+ },
+ "notes": "An inline colon payload decodes backtick newline before recursion."
+}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/248_iex_decoded_nul.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/248_iex_decoded_nul.json
new file mode 100644
index 0000000..9b086d6
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/248_iex_decoded_nul.json
@@ -0,0 +1,10 @@
+{
+ "name": "Iex decoded nul",
+ "input": "iex \u0022Remove-Item\u00600C:\\x\u0022",
+ "expected": {
+ "isUnparseable": true,
+ "unparseableReasonContains": "NUL character is not supported"
+ },
+ "notes": "A decoded NUL safe-fails instead of merging a hidden path into the verb.",
+ "oracleExpectation": "OutOfScope"
+}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/249_iex_command_colon_unicode_escape.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/249_iex_command_colon_unicode_escape.json
new file mode 100644
index 0000000..08393d2
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/249_iex_command_colon_unicode_escape.json
@@ -0,0 +1,19 @@
+{
+ "name": "Iex command colon unicode escape",
+ "input": "iex -Command:Get-\u0060u{44}ate",
+ "expected": {
+ "isUnparseable": false,
+ "clauses": [
+ {
+ "operator": "None",
+ "verb": [
+ "Get-Date"
+ ],
+ "args": [],
+ "redirects": [],
+ "isCommandStringWrapped": true
+ }
+ ]
+ },
+ "notes": "An inline colon payload consumes and decodes a complete Unicode escape."
+}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/250_iex_dot_invocation.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/250_iex_dot_invocation.json
new file mode 100644
index 0000000..7069d0b
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/250_iex_dot_invocation.json
@@ -0,0 +1,10 @@
+{
+ "name": "Iex dot invocation",
+ "input": ". iex \u0027Remove-Item C:\\x\u0027",
+ "expected": {
+ "isUnparseable": true,
+ "unparseableReasonContains": "the dot-source invocation operator is not supported in v0.2"
+ },
+ "notes": "Dot invocation is valid PowerShell but outside the parser grammar and safe-fails.",
+ "oracleExpectation": "OutOfScope"
+}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/251_iex_scoped_interpolation_dynamic.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/251_iex_scoped_interpolation_dynamic.json
new file mode 100644
index 0000000..6da10cd
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/251_iex_scoped_interpolation_dynamic.json
@@ -0,0 +1,25 @@
+{
+ "name": "Iex scoped interpolation dynamic",
+ "input": "iex \u0022Remove-$:noun C:\\x\u0022",
+ "expected": {
+ "isUnparseable": false,
+ "clauses": [
+ {
+ "operator": "None",
+ "verb": [
+ "iex"
+ ],
+ "canonicalVerb": "Invoke-Expression",
+ "args": [
+ {
+ "raw": "\u0022Remove-$:noun C:\\x\u0022",
+ "kind": "DynamicSkip",
+ "isPath": false
+ }
+ ],
+ "redirects": []
+ }
+ ]
+ },
+ "notes": "Scoped interpolation syntax keeps the expression payload dynamic."
+}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/252_iex_module_qualified_remove.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/252_iex_module_qualified_remove.json
new file mode 100644
index 0000000..011a9a3
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/252_iex_module_qualified_remove.json
@@ -0,0 +1,10 @@
+{
+ "name": "Iex module qualified remove",
+ "input": "iex \u0027Microsoft.PowerShell.Management\\Remove-Item C:\\x\u0027",
+ "expected": {
+ "isUnparseable": true,
+ "unparseableReasonContains": "module-qualified cmdlet \u0027Microsoft.PowerShell.Management\\Remove-Item\u0027 is not supported in v0.2"
+ },
+ "notes": "An unsupported module-qualified inner cmdlet safe-fails instead of hiding its identity.",
+ "oracleExpectation": "OutOfScope"
+}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/253_iex_module_qualified_location.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/253_iex_module_qualified_location.json
new file mode 100644
index 0000000..7173d30
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/253_iex_module_qualified_location.json
@@ -0,0 +1,10 @@
+{
+ "name": "Iex module qualified location",
+ "input": "Set-Location C:\\safe; iex \u0027Microsoft.PowerShell.Management\\Set-Location C:\\evil\u0027; Remove-Item child.txt",
+ "expected": {
+ "isUnparseable": true,
+ "unparseableReasonContains": "module-qualified cmdlet \u0027Microsoft.PowerShell.Management\\Set-Location\u0027 is not supported in v0.2"
+ },
+ "notes": "An unsupported module-qualified location mutation safe-fails instead of preserving stale cwd.",
+ "oracleExpectation": "OutOfScope"
+}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/254_dynamic_command_location.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/254_dynamic_command_location.json
new file mode 100644
index 0000000..f344f2d
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/254_dynamic_command_location.json
@@ -0,0 +1,67 @@
+{
+ "name": "Dynamic command location",
+ "input": "Set-Location C:\\safe; \u0026 \u0022i$part\u0022 $code; Get-Item child.txt",
+ "expected": {
+ "isUnparseable": false,
+ "clauses": [
+ {
+ "operator": "None",
+ "verb": [
+ "Set-Location"
+ ],
+ "args": [
+ {
+ "raw": "C:\\safe",
+ "kind": "Literal",
+ "isPath": true,
+ "resolved": "C:/safe"
+ }
+ ],
+ "redirects": []
+ },
+ {
+ "operator": "Sequence",
+ "verb": [
+ "i$part"
+ ],
+ "isDynamic": true,
+ "args": [
+ {
+ "raw": "$code",
+ "kind": "EnvVar",
+ "isPath": false
+ },
+ {
+ "raw": "C:/safe",
+ "kind": "Literal",
+ "isPath": true,
+ "resolved": "C:/safe",
+ "isCwdAttribution": true
+ }
+ ],
+ "redirects": []
+ },
+ {
+ "operator": "Sequence",
+ "verb": [
+ "Get-Item"
+ ],
+ "args": [
+ {
+ "raw": "child.txt",
+ "kind": "DynamicSkip",
+ "isPath": false
+ },
+ {
+ "raw": "\u003Cdynamic-cwd\u003E",
+ "kind": "DynamicSkip",
+ "isPath": false,
+ "isCwdAttribution": true
+ }
+ ],
+ "redirects": []
+ }
+ ]
+ },
+ "notes": "Any dynamic command invalidates following current-scope location attribution."
+}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/255_quoted_expression_command.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/255_quoted_expression_command.json
new file mode 100644
index 0000000..47b28cc
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/255_quoted_expression_command.json
@@ -0,0 +1,9 @@
+{
+ "name": "Quoted expression command",
+ "input": "\u0022iex\u0022 \u0027Get-Date\u0027",
+ "expected": {
+ "isUnparseable": true,
+ "unparseableReasonContains": "a quoted expression at command position is not supported in v0.2"
+ },
+ "notes": "A quoted expression without the call operator is not a command invocation."
+}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/256_iex_quoted_inner_expression.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/256_iex_quoted_inner_expression.json
new file mode 100644
index 0000000..c6f888f
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/256_iex_quoted_inner_expression.json
@@ -0,0 +1,10 @@
+{
+ "name": "Iex quoted inner expression",
+ "input": "Set-Location C:\\safe; iex \u0027\u0022Set-Location\u0022 C:\\evil\u0027; Get-Item child.txt",
+ "expected": {
+ "isUnparseable": true,
+ "unparseableReasonContains": "a quoted expression at command position is not supported in v0.2"
+ },
+ "notes": "A quoted inner expression is valid outer syntax but not a supported command invocation.",
+ "oracleExpectation": "OutOfScope"
+}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/257_iex_escape_character.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/257_iex_escape_character.json
new file mode 100644
index 0000000..eda0b66
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/257_iex_escape_character.json
@@ -0,0 +1,70 @@
+{
+ "name": "Iex escape character",
+ "input": "Set-Location C:\\safe; iex \u0022S\u0060et-Location C:\\evil\u0022; Get-Item child.txt",
+ "expected": {
+ "isUnparseable": false,
+ "clauses": [
+ {
+ "operator": "None",
+ "verb": [
+ "Set-Location"
+ ],
+ "args": [
+ {
+ "raw": "C:\\safe",
+ "kind": "Literal",
+ "isPath": true,
+ "resolved": "C:/safe"
+ }
+ ],
+ "redirects": []
+ },
+ {
+ "operator": "Sequence",
+ "verb": [
+ "S\u001Bt-Location"
+ ],
+ "args": [
+ {
+ "raw": "C:\\evil",
+ "kind": "Literal",
+ "isPath": true,
+ "resolved": "C:/evil"
+ },
+ {
+ "raw": "C:/safe",
+ "kind": "Literal",
+ "isPath": true,
+ "resolved": "C:/safe",
+ "isCwdAttribution": true
+ }
+ ],
+ "redirects": [],
+ "isCommandStringWrapped": true
+ },
+ {
+ "operator": "Sequence",
+ "verb": [
+ "Get-Item"
+ ],
+ "args": [
+ {
+ "raw": "child.txt",
+ "kind": "Literal",
+ "isPath": true,
+ "resolved": "C:/safe/child.txt"
+ },
+ {
+ "raw": "C:/safe",
+ "kind": "Literal",
+ "isPath": true,
+ "resolved": "C:/safe",
+ "isCwdAttribution": true
+ }
+ ],
+ "redirects": []
+ }
+ ]
+ },
+ "notes": "The backtick e escape becomes ESC and cannot spoof Set-Location."
+}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/258_iex_invalid_unicode_empty.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/258_iex_invalid_unicode_empty.json
new file mode 100644
index 0000000..bff0fcf
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/258_iex_invalid_unicode_empty.json
@@ -0,0 +1,9 @@
+{
+ "name": "Iex invalid unicode empty",
+ "input": "iex \u0022Get-\u0060u{}Date\u0022",
+ "expected": {
+ "isUnparseable": true,
+ "unparseableReasonContains": "invalid PowerShell Unicode escape"
+ },
+ "notes": "An empty Unicode escape is a PowerShell syntax error."
+}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/259_iex_invalid_unicode_range.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/259_iex_invalid_unicode_range.json
new file mode 100644
index 0000000..4ecbdfa
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/259_iex_invalid_unicode_range.json
@@ -0,0 +1,9 @@
+{
+ "name": "Iex invalid unicode range",
+ "input": "iex \u0022Get-\u0060u{110000}Date\u0022",
+ "expected": {
+ "isUnparseable": true,
+ "unparseableReasonContains": "invalid PowerShell Unicode escape"
+ },
+ "notes": "An out-of-range Unicode escape is a PowerShell syntax error."
+}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/260_iex_invalid_unicode_colon.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/260_iex_invalid_unicode_colon.json
new file mode 100644
index 0000000..d452469
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/260_iex_invalid_unicode_colon.json
@@ -0,0 +1,9 @@
+{
+ "name": "Iex invalid unicode colon",
+ "input": "iex -Command:Get-\u0060u{}Date",
+ "expected": {
+ "isUnparseable": true,
+ "unparseableReasonContains": "invalid PowerShell Unicode escape"
+ },
+ "notes": "Malformed Unicode syntax in an inline colon payload safe-fails."
+}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/261_iex_quoted_module_remove.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/261_iex_quoted_module_remove.json
new file mode 100644
index 0000000..73457e2
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/261_iex_quoted_module_remove.json
@@ -0,0 +1,10 @@
+{
+ "name": "Iex quoted module remove",
+ "input": "iex \u0022\u0026 \u0027Microsoft.PowerShell.Management\\Remove-Item\u0027 C:\\x\u0022",
+ "expected": {
+ "isUnparseable": true,
+ "unparseableReasonContains": "module-qualified cmdlet \u0027Microsoft.PowerShell.Management\\Remove-Item\u0027 is not supported in v0.2"
+ },
+ "notes": "A quoted module-qualified inner cmdlet safe-fails under the call operator.",
+ "oracleExpectation": "OutOfScope"
+}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/262_iex_quoted_module_location.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/262_iex_quoted_module_location.json
new file mode 100644
index 0000000..8917205
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/262_iex_quoted_module_location.json
@@ -0,0 +1,10 @@
+{
+ "name": "Iex quoted module location",
+ "input": "Set-Location C:\\safe; iex \u0022\u0026 \u0027Microsoft.PowerShell.Management\\Set-Location\u0027 C:\\evil\u0022; Remove-Item child.txt",
+ "expected": {
+ "isUnparseable": true,
+ "unparseableReasonContains": "module-qualified cmdlet \u0027Microsoft.PowerShell.Management\\Set-Location\u0027 is not supported in v0.2"
+ },
+ "notes": "A quoted module-qualified location mutation cannot preserve stale cwd.",
+ "oracleExpectation": "OutOfScope"
+}
diff --git a/tests/ShellSyntaxTree.Tests/Lexing/PwshLexerTests.cs b/tests/ShellSyntaxTree.Tests/Lexing/PwshLexerTests.cs
index 256a766..173f35a 100644
--- a/tests/ShellSyntaxTree.Tests/Lexing/PwshLexerTests.cs
+++ b/tests/ShellSyntaxTree.Tests/Lexing/PwshLexerTests.cs
@@ -63,6 +63,57 @@ public void Double_quoted_string_keeps_var_literal()
Assert.Equal(PwshTokenKind.QuotedString, t.Kind);
Assert.Equal("path $env:TEMP", t.Value);
Assert.False(t.IsSingleQuoted);
+ Assert.True(t.HasInterpolation);
+ }
+
+ [Theory]
+ [InlineData("\"Get-$noun\"")]
+ [InlineData("\"Get-$(Get-Variable noun)\"")]
+ [InlineData("\"Get-$é\"")]
+ [InlineData("\"Get-$:noun\"")]
+ public void Expandable_string_records_interpolation(string input)
+ {
+ var t = Assert.Single(Significant(input));
+ Assert.True(t.HasInterpolation);
+ }
+
+ [Theory]
+ [InlineData("\"Get-Date\"")]
+ [InlineData("\"Write-Host `$name\"")]
+ [InlineData("\"Write-Output $.\"")]
+ public void Static_expandable_string_has_no_interpolation(string input)
+ {
+ var t = Assert.Single(Significant(input));
+ Assert.False(t.HasInterpolation);
+ }
+
+ [Fact]
+ public void Expandable_here_string_records_interpolation()
+ {
+ var t = Assert.Single(Significant("@\"\nGet-$noun\n\"@"));
+ Assert.True(t.IsHereString);
+ Assert.True(t.HasInterpolation);
+ }
+
+ [Fact]
+ public void Expandable_here_string_decodes_backtick_newline_escape()
+ {
+ var t = Assert.Single(Significant("@\"\nWrite-Output ok`nGet-Date\n\"@"));
+ Assert.Equal("Write-Output ok\nGet-Date", t.Value);
+ }
+
+ [Fact]
+ public void Expandable_here_string_records_unicode_interpolation()
+ {
+ var t = Assert.Single(Significant("@\"\nGet-$é\n\"@"));
+ Assert.True(t.HasInterpolation);
+ }
+
+ [Fact]
+ public void Expandable_here_string_ignores_literal_dollar_punctuation()
+ {
+ var t = Assert.Single(Significant("@\"\nWrite-Output $.\n\"@"));
+ Assert.False(t.HasInterpolation);
}
[Fact]
@@ -72,6 +123,70 @@ public void Double_quote_recognizes_backtick_escapes()
Assert.Equal("a\tb", t.Value);
}
+ [Fact]
+ public void Double_quote_decodes_unicode_escape()
+ {
+ var t = Assert.Single(Significant("\"i`u{65}x\""));
+ Assert.Equal("iex", t.Value);
+ }
+
+ [Fact]
+ public void Double_quote_decodes_escape_character()
+ {
+ var t = Assert.Single(Significant("\"S`et\""));
+ Assert.Equal(new[] { 83, 27, 116 }, t.Value.Select(c => (int)c));
+ }
+
+ [Theory]
+ [InlineData("\"Get-`u{}Date\"")]
+ [InlineData("\"Get-`u{110000}Date\"")]
+ public void Malformed_unicode_escape_emits_sentinel(string input)
+ {
+ var t = Assert.Single(Significant(input));
+ Assert.Equal(PwshTokenKind.UnparseableSentinel, t.Kind);
+ }
+
+ [Fact]
+ public void Unicode_escape_allows_utf16_surrogate_code_unit()
+ {
+ var t = Assert.Single(Significant("\"`u{D800}\""));
+ Assert.Equal(1, t.Value.Length);
+ Assert.Equal(0xD800, t.Value[0]);
+ }
+
+ [Fact]
+ public void Bare_word_decodes_backtick_whitespace_and_newline_escape()
+ {
+ var t = Assert.Single(Significant("Write-Output` harmless`nGet-Date"));
+ Assert.Equal("Write-Output harmless\nGet-Date", t.Value);
+ }
+
+ [Fact]
+ public void Bare_word_decodes_unicode_escape()
+ {
+ var t = Assert.Single(Significant("i`u{65}x"));
+ Assert.Equal("iex", t.Value);
+ }
+
+ [Theory]
+ [InlineData("Remove-Item\vC:\\x")]
+ [InlineData("Remove-Item\fC:\\x")]
+ [InlineData("Remove-Item\u2003C:\\x")]
+ public void PowerShell_inline_whitespace_separates_words(string input)
+ {
+ var tokens = Significant(input);
+ Assert.Equal(2, tokens.Length);
+ Assert.Equal("Remove-Item", tokens[0].Value);
+ Assert.Equal("C:\\x", tokens[1].Value);
+ }
+
+ [Fact]
+ public void Nul_character_emits_unparseable_sentinel()
+ {
+ var token = Assert.Single(Significant("\0"));
+ Assert.Equal(PwshTokenKind.UnparseableSentinel, token.Kind);
+ }
+
[Fact]
public void Unbalanced_single_quote_emits_sentinel()
{
@@ -97,6 +212,29 @@ public void Colon_form_parameter_keeps_its_value()
Assert.Equal("-Path:C:\\logs", tokens[1].Value);
}
+ [Fact]
+ public void Comment_after_empty_colon_value_starts_a_comment()
+ {
+ var tokens = Significant("iex -Command:#comment");
+ Assert.Equal(2, tokens.Length);
+ Assert.Equal("-Command:", tokens[1].Value);
+ }
+
+ [Fact]
+ public void Colon_value_scanner_consumes_complete_unicode_escape()
+ {
+ var tokens = Significant("iex -Command:Get-`u{44}ate");
+ Assert.Equal(2, tokens.Length);
+ Assert.Equal("-Command:Get-`u{44}ate", tokens[1].Value);
+ }
+
+ [Fact]
+ public void Malformed_colon_value_unicode_escape_emits_sentinel()
+ {
+ var tokens = Significant("iex -Command:Get-`u{}Date");
+ Assert.Contains(tokens, t => t.Kind == PwshTokenKind.UnparseableSentinel);
+ }
+
[Theory]
[InlineData("git --work-tree repo", "--work-tree")]
[InlineData("Get-Thing -Name-Part:value", "-Name-Part:value")]
diff --git a/tests/ShellSyntaxTree.Tests/Parsing/PwshCommandParserTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/PwshCommandParserTests.cs
index a3ebe4e..5353e06 100644
--- a/tests/ShellSyntaxTree.Tests/Parsing/PwshCommandParserTests.cs
+++ b/tests/ShellSyntaxTree.Tests/Parsing/PwshCommandParserTests.cs
@@ -3,7 +3,9 @@
// Copyright (C) 2026 - 2026 Aaron Stannard
//
// -----------------------------------------------------------------------
+using System;
using System.Linq;
+using System.Text;
using Xunit;
namespace ShellSyntaxTree.Tests.Parsing;
@@ -562,6 +564,428 @@ public void Inner_unparseable_command_propagates_to_the_outer()
Assert.True(result.IsUnparseable);
}
+ [Theory]
+ [InlineData("Invoke-Expression 'Get-Date'")]
+ [InlineData("iex 'Get-Date'")]
+ [InlineData("iex \"Get-Date\"")]
+ [InlineData("Invoke-Expression Get-Date")]
+ [InlineData("Invoke-Expression -Command 'Get-Date'")]
+ public void Invoke_expression_static_payload_surfaces_inner_clause(string input)
+ {
+ var result = Parse(input);
+ var clause = Assert.Single(result.Clauses);
+ Assert.Equal(new[] { "Get-Date" }, clause.Verb.Tokens);
+ Assert.True(clause.IsCommandStringWrapped);
+ }
+
+ [Fact]
+ public void Invoke_expression_static_compound_surfaces_all_inner_clauses()
+ {
+ var result = Parse("Get-Service; iex 'Get-Date; Get-Process'");
+
+ Assert.Equal(3, result.Clauses.Count);
+ Assert.Equal(CompoundOperator.Sequence, result.Clauses[1].Operator);
+ Assert.Equal(new[] { "Get-Date" }, result.Clauses[1].Verb.Tokens);
+ Assert.Equal(CompoundOperator.Sequence, result.Clauses[2].Operator);
+ Assert.All(result.Clauses.Skip(1), c => Assert.True(c.IsCommandStringWrapped));
+ }
+
+ [Fact]
+ public void Invoke_expression_static_expandable_here_string_is_recursed()
+ {
+ var clause = Assert.Single(Parse("iex @\"\nGet-Date\n\"@").Clauses);
+
+ Assert.Equal(new[] { "Get-Date" }, clause.Verb.Tokens);
+ Assert.True(clause.IsCommandStringWrapped);
+ }
+
+ [Theory]
+ [InlineData("& 'iex' 'Get-Date'")]
+ [InlineData("Microsoft.PowerShell.Utility\\Invoke-Expression 'Get-Date'")]
+ public void Alternate_invoke_expression_names_recurse_static_payloads(string input)
+ {
+ var clause = Assert.Single(Parse(input).Clauses);
+ Assert.Equal(new[] { "Get-Date" }, clause.Verb.Tokens);
+ Assert.True(clause.IsCommandStringWrapped);
+ }
+
+ [Fact]
+ public void Invoke_expression_escaped_dollar_is_a_static_outer_string()
+ {
+ var result = Parse("iex \"Write-Host `$name\"");
+ var clause = Assert.Single(result.Clauses);
+
+ Assert.Equal(new[] { "Write-Host" }, clause.Verb.Tokens);
+ Assert.True(clause.IsCommandStringWrapped);
+ Assert.Equal(ArgKind.EnvVar, Assert.Single(clause.Args).Kind);
+ }
+
+ [Theory]
+ [InlineData("Invoke-Expression $code", "$code")]
+ [InlineData("iex \"Remove-$noun C:\\x\"", "\"Remove-$noun C:\\x\"")]
+ [InlineData("iex \"Remove-$é C:\\x\"", "\"Remove-$é C:\\x\"")]
+ [InlineData("iex $(Get-Content script.ps1)", "$(Get-Content script.ps1)")]
+ [InlineData("iex ('Get-' + 'Date')", "('Get-' + 'Date')")]
+ [InlineData("Invoke-Expression Write-Output,OTHER", "Write-Output,OTHER")]
+ public void Invoke_expression_computed_payload_is_one_dynamic_arg(
+ string input, string expectedRaw)
+ {
+ var result = Parse(input);
+ var clause = Assert.Single(result.Clauses);
+ var arg = Assert.Single(clause.Args);
+
+ Assert.Equal("Invoke-Expression", clause.Verb.CanonicalVerb ?? clause.Verb.Tokens[0]);
+ Assert.Equal(expectedRaw, arg.Raw);
+ Assert.Equal(ArgKind.DynamicSkip, arg.Kind);
+ Assert.False(arg.IsPath);
+ Assert.Null(arg.Resolved);
+ }
+
+ [Theory]
+ [InlineData("Invoke-Expression")]
+ [InlineData("Get-Content script.ps1 | Invoke-Expression")]
+ [InlineData("'Get-Date' | Invoke-Expression 'Get-Process'")]
+ [InlineData("Invoke-Expression -Verbose 'Get-Date'")]
+ public void Invoke_expression_unbound_payload_is_unparseable(string input)
+ {
+ Assert.True(Parse(input).IsUnparseable);
+ }
+
+ [Theory]
+ [InlineData("& 'iex' $code")]
+ [InlineData("Microsoft.PowerShell.Utility\\Invoke-Expression $code")]
+ public void Alternate_invoke_expression_names_still_safe_fail_dynamic_payloads(
+ string input)
+ {
+ var clause = Assert.Single(Parse(input).Clauses);
+ Assert.Equal(ArgKind.DynamicSkip, Assert.Single(clause.Args).Kind);
+ if (input.StartsWith("&"))
+ {
+ Assert.Equal("Invoke-Expression", clause.Verb.CanonicalVerb);
+ }
+ }
+
+ [Fact]
+ public void Unicode_escaped_iex_name_still_safe_fails_dynamic_payload()
+ {
+ var clause = Assert.Single(Parse("& \"i`u{65}x\" $code").Clauses);
+
+ Assert.Equal("Invoke-Expression", clause.Verb.CanonicalVerb);
+ Assert.Equal(ArgKind.DynamicSkip, Assert.Single(clause.Args).Kind);
+ }
+
+ [Fact]
+ public void Bare_backtick_newline_payload_surfaces_every_command()
+ {
+ var result = Parse("iex Write-Output` harmless`nGet-Date");
+
+ Assert.Equal(2, result.Clauses.Count);
+ Assert.Equal(new[] { "Write-Output" }, result.Clauses[0].Verb.Tokens);
+ Assert.Equal(new[] { "Get-Date" }, result.Clauses[1].Verb.Tokens);
+ Assert.All(result.Clauses, c => Assert.True(c.IsCommandStringWrapped));
+ }
+
+ [Fact]
+ public void Expandable_here_string_backtick_newline_surfaces_every_command()
+ {
+ var result = Parse("iex @\"\nWrite-Output ok`nGet-Date\n\"@");
+
+ Assert.Equal(2, result.Clauses.Count);
+ Assert.Equal(new[] { "Write-Output" }, result.Clauses[0].Verb.Tokens);
+ Assert.Equal(new[] { "Get-Date" }, result.Clauses[1].Verb.Tokens);
+ }
+
+ [Fact]
+ public void Backtick_newline_payload_updates_outer_location()
+ {
+ var result = Parse(
+ "Set-Location C:\\safe; iex Write-Output` ok`nSet-Location` C:\\evil; Remove-Item child.txt");
+ var remove = result.Clauses.Last();
+
+ Assert.Contains(remove.Args,
+ a => a.Raw == "child.txt" && a.Resolved == "C:/evil/child.txt");
+ }
+
+ [Theory]
+ [InlineData("iex \"Remove-Item`vC:\\x\"")]
+ [InlineData("iex \"Remove-Item`fC:\\x\"")]
+ [InlineData("iex \"Remove-Item`u{2003}C:\\x\"")]
+ public void Decoded_inline_whitespace_separates_inner_path(string input)
+ {
+ var clause = Assert.Single(Parse(input).Clauses);
+ Assert.Equal(new[] { "Remove-Item" }, clause.Verb.Tokens);
+ Assert.Contains(clause.Args, a => a.Raw == "C:\\x" && a.Resolved == "C:/x");
+ }
+
+ [Fact]
+ public void Decoded_vertical_tab_location_change_updates_outer_location()
+ {
+ var result = Parse(
+ "Set-Location C:\\safe; iex \"Set-Location`vC:\\evil\"; Remove-Item child.txt");
+ var remove = result.Clauses.Last();
+
+ Assert.Contains(remove.Args,
+ a => a.Raw == "child.txt" && a.Resolved == "C:/evil/child.txt");
+ }
+
+ [Fact]
+ public void Decoded_nul_payload_is_unparseable()
+ {
+ Assert.True(Parse("iex \"Remove-Item`0C:\\x\"").IsUnparseable);
+ }
+
+ [Theory]
+ [InlineData("iex -Command:Get-Date")]
+ [InlineData("iex -Command:'Get-Date'")]
+ [InlineData("iex -Command:\"Get-Date\"")]
+ public void Invoke_expression_colon_command_parameter_binds_static_payload(
+ string input)
+ {
+ var clause = Assert.Single(Parse(input).Clauses);
+ Assert.Equal(new[] { "Get-Date" }, clause.Verb.Tokens);
+ Assert.True(clause.IsCommandStringWrapped);
+ }
+
+ [Fact]
+ public void Invoke_expression_colon_command_parameter_keeps_dynamic_payload_opaque()
+ {
+ var clause = Assert.Single(Parse("iex -Command:$code").Clauses);
+ Assert.Equal(ArgKind.DynamicSkip, Assert.Single(clause.Args).Kind);
+ }
+
+ [Fact]
+ public void Invoke_expression_colon_comment_is_a_missing_payload()
+ {
+ Assert.True(Parse("iex -Command:#comment").IsUnparseable);
+ }
+
+ [Fact]
+ public void Invoke_expression_colon_backtick_newline_surfaces_every_command()
+ {
+ var result = Parse("iex -Command:Write-Output`nGet-Date");
+
+ Assert.Equal(2, result.Clauses.Count);
+ Assert.Equal(new[] { "Write-Output" }, result.Clauses[0].Verb.Tokens);
+ Assert.Equal(new[] { "Get-Date" }, result.Clauses[1].Verb.Tokens);
+ }
+
+ [Fact]
+ public void Invoke_expression_colon_unicode_escape_is_decoded()
+ {
+ var clause = Assert.Single(Parse("iex -Command:Get-`u{44}ate").Clauses);
+ Assert.Equal(new[] { "Get-Date" }, clause.Verb.Tokens);
+ Assert.True(clause.IsCommandStringWrapped);
+ }
+
+ [Fact]
+ public void Custom_module_alias_is_not_treated_as_invoke_expression()
+ {
+ const string input = "OtherModule\\iex $code";
+ var clause = Assert.Single(Parse(input).Clauses);
+ Assert.StartsWith("OtherModule\\", clause.Verb.Tokens[0]);
+ Assert.Null(clause.Verb.CanonicalVerb);
+ Assert.DoesNotContain(clause.Args, a => a.Kind == ArgKind.DynamicSkip);
+ }
+
+ [Fact]
+ public void Custom_module_qualified_cmdlet_is_unparseable()
+ {
+ Assert.True(Parse("OtherModule\\Invoke-Expression $code").IsUnparseable);
+ }
+
+ [Theory]
+ [InlineData("& \"i$part\" $code")]
+ [InlineData("& i$part $code")]
+ public void Interpolated_call_operator_command_name_is_dynamic(string input)
+ {
+ var clause = Assert.Single(Parse(input).Clauses);
+ Assert.True(clause.Verb.IsDynamic);
+ }
+
+ [Fact]
+ public void Scoped_interpolation_in_invoke_expression_payload_is_dynamic()
+ {
+ var clause = Assert.Single(Parse("iex \"Remove-$:noun C:\\x\"").Clauses);
+ Assert.Equal(ArgKind.DynamicSkip, Assert.Single(clause.Args).Kind);
+ }
+
+ [Fact]
+ public void Dot_invoked_iex_is_unparseable()
+ {
+ Assert.True(Parse(". iex 'Remove-Item C:\\x'").IsUnparseable);
+ }
+
+ [Theory]
+ [InlineData("iex 'Microsoft.PowerShell.Management\\Remove-Item C:\\x'")]
+ [InlineData("Set-Location C:\\safe; iex 'Microsoft.PowerShell.Management\\Set-Location C:\\evil'; Remove-Item child.txt")]
+ public void Module_qualified_inner_cmdlet_is_unparseable(string input)
+ {
+ Assert.True(Parse(input).IsUnparseable);
+ }
+
+ [Theory]
+ [InlineData("iex \"& 'Microsoft.PowerShell.Management\\Remove-Item' C:\\x\"")]
+ [InlineData("Set-Location C:\\safe; iex \"& 'Microsoft.PowerShell.Management\\Set-Location' C:\\evil\"; Remove-Item child.txt")]
+ [InlineData("& \"Microsoft.PowerShell.Management\\Remove-Item\" C:\\x")]
+ public void Quoted_module_qualified_cmdlet_is_unparseable(string input)
+ {
+ Assert.True(Parse(input).IsUnparseable);
+ }
+
+ [Fact]
+ public void Dynamic_command_invalidates_following_location()
+ {
+ var result = Parse(
+ "Set-Location C:\\safe; & \"i$part\" $code; Get-Item child.txt");
+ var item = result.Clauses.Last();
+ var child = Assert.Single(item.Args, a => a.Raw == "child.txt");
+
+ Assert.Equal(ArgKind.DynamicSkip, child.Kind);
+ Assert.Contains(item.Args,
+ a => a.IsCwdAttribution && a.Kind == ArgKind.DynamicSkip);
+ }
+
+ [Theory]
+ [InlineData("\"iex\" 'Get-Date'")]
+ [InlineData("Set-Location C:\\safe; iex '\"Set-Location\" C:\\evil'; Get-Item child.txt")]
+ public void Quoted_expression_without_call_operator_is_unparseable(string input)
+ {
+ Assert.True(Parse(input).IsUnparseable);
+ }
+
+ [Fact]
+ public void Escape_character_does_not_create_set_location_identity()
+ {
+ var result = Parse(
+ "Set-Location C:\\safe; iex \"S`et-Location C:\\evil\"; Get-Item child.txt");
+ var item = result.Clauses.Last();
+
+ Assert.Contains(item.Args,
+ a => a.Raw == "child.txt" && a.Resolved == "C:/safe/child.txt");
+ }
+
+ [Theory]
+ [InlineData("iex \"Get-`u{}Date\"")]
+ [InlineData("iex \"Get-`u{110000}Date\"")]
+ [InlineData("iex -Command:Get-`u{}Date")]
+ public void Malformed_unicode_escape_is_unparseable(string input)
+ {
+ Assert.True(Parse(input).IsUnparseable);
+ }
+
+ [Fact]
+ public void Invoke_expression_inherits_the_current_location()
+ {
+ var result = Parse("Set-Location C:\\a; iex 'Remove-Item child.txt'");
+ var remove = result.Clauses.Last();
+
+ Assert.Equal("Remove-Item", remove.Verb.Tokens[0]);
+ Assert.Contains(remove.Args, a => a.Raw == "child.txt" && a.Resolved == "C:/a/child.txt");
+ }
+
+ [Fact]
+ public void Invoke_expression_exports_location_changes_to_outer_clauses()
+ {
+ var result = Parse(
+ "Set-Location C:\\a; iex 'Set-Location C:\\b; Remove-Item child.txt'; Get-ChildItem child.txt");
+
+ Assert.Contains(result.Clauses[2].Args,
+ a => a.Raw == "child.txt" && a.Resolved == "C:/b/child.txt");
+ Assert.Contains(result.Clauses[3].Args,
+ a => a.Raw == "child.txt" && a.Resolved == "C:/b/child.txt");
+ }
+
+ [Fact]
+ public void Dynamic_invoke_expression_invalidates_following_location()
+ {
+ var result = Parse(
+ "Set-Location C:\\safe; Invoke-Expression $code; Remove-Item child.txt");
+ var remove = result.Clauses.Last();
+ var child = Assert.Single(remove.Args, a => a.Raw == "child.txt");
+
+ Assert.Equal(ArgKind.DynamicSkip, child.Kind);
+ Assert.False(child.IsPath);
+ Assert.Null(child.Resolved);
+ Assert.Contains(remove.Args,
+ a => a.IsCwdAttribution && a.Kind == ArgKind.DynamicSkip);
+ var expression = result.Clauses[1];
+ Assert.Contains(expression.Args,
+ a => a.IsCwdAttribution && a.Resolved == "C:/safe");
+ }
+
+ [Fact]
+ public void Child_pwsh_location_change_remains_isolated()
+ {
+ var result = Parse(
+ "pwsh -Command 'Set-Location C:\\b'; Remove-Item child.txt");
+ var remove = result.Clauses.Last();
+
+ Assert.Contains(remove.Args,
+ a => a.Raw == "child.txt" && a.Resolved == "C:/work/child.txt");
+ }
+
+ [Fact]
+ public void Invoke_expression_depth_five_parses()
+ {
+ var result = Parse(NestInvokeExpression("Get-Date", 5));
+
+ Assert.False(result.IsUnparseable);
+ Assert.Equal(new[] { "Get-Date" }, Assert.Single(result.Clauses).Verb.Tokens);
+ }
+
+ [Fact]
+ public void Invoke_expression_depth_six_is_unparseable()
+ {
+ var result = Parse(NestInvokeExpression("Get-Date", 6));
+
+ Assert.True(result.IsUnparseable);
+ Assert.Contains("recursion depth", result.UnparseableReason!);
+ }
+
+ [Fact]
+ public void Mixed_command_string_wrappers_share_the_depth_limit()
+ {
+ var inner = "Get-Date";
+ for (var i = 0; i < 6; i++)
+ {
+ var escaped = inner.Replace("'", "''");
+ inner = (i % 3) switch
+ {
+ 0 => "iex '" + escaped + "'",
+ 1 => "pwsh -Command '" + escaped + "'",
+ _ => "pwsh -EncodedCommand "
+ + Convert.ToBase64String(Encoding.Unicode.GetBytes(inner)),
+ };
+ }
+
+ Assert.True(Parse(inner).IsUnparseable);
+ }
+
+ [Fact]
+ public void Oversized_invoke_expression_input_is_unparseable()
+ {
+ var result = Parse("iex '" + new string('x', (64 * 1024) + 1) + "'");
+
+ Assert.True(result.IsUnparseable);
+ Assert.Contains("64 KiB", result.UnparseableReason!);
+ }
+
+ [Fact]
+ public void Invoke_expression_inner_anomaly_propagates()
+ {
+ Assert.True(Parse("iex 'if ($true) { Get-Date }'").IsUnparseable);
+ }
+
+ private static string NestInvokeExpression(string inner, int depth)
+ {
+ for (var i = 0; i < depth; i++)
+ {
+ inner = "iex '" + inner.Replace("'", "''") + "'";
+ }
+
+ return inner;
+ }
+
// ---------------------------------------------------------------- anomalies
[Theory]
diff --git a/tools/PwshCorpusTool/CorpusManifest.cs b/tools/PwshCorpusTool/CorpusManifest.cs
index 8102043..094c534 100644
--- a/tools/PwshCorpusTool/CorpusManifest.cs
+++ b/tools/PwshCorpusTool/CorpusManifest.cs
@@ -65,6 +65,16 @@ private static ManifestEntry Big(string slug, string prefix, string notes) =>
private static string B64(string s) =>
Convert.ToBase64String(Encoding.Unicode.GetBytes(s));
+ private static string NestIex(string inner, int depth)
+ {
+ for (var i = 0; i < depth; i++)
+ {
+ inner = "iex '" + inner.Replace("'", "''") + "'";
+ }
+
+ return inner;
+ }
+
internal static IReadOnlyList All() => new List
{
// ---- Simple cmdlet (§13: ≥10) ----
@@ -344,8 +354,8 @@ private static string B64(string s) =>
"A malformed base64 -EncodedCommand payload — the outer pwsh invocation still parses."),
E("recursion_file_not_recursed", "pwsh -File C:\\scripts\\deploy.ps1",
"pwsh -File is not recursion; the script path is an ordinary path arg."),
- E("recursion_iex_not_recursed", "iex \"Remove-Item C:\\x\"",
- "Invoke-Expression / iex is never recursed into (§10)."),
+ E("recursion_iex_static", "iex \"Remove-Item C:\\x\"",
+ "A static iex payload surfaces its inner Remove-Item clause (§10)."),
Oos("recursion_depth_overflow",
"pwsh -Command { pwsh -Command { pwsh -Command { pwsh -Command { pwsh -Command { pwsh -Command { Get-Date } } } } } }",
"Six nested -Command levels exceed the depth-5 cap."),
@@ -482,5 +492,93 @@ private static string B64(string s) =>
// ---- Issue #64: path-shaped operands after native verb chains ----
E("native_kubectl_apply_yaml", "kubectl apply deployment.yaml",
"A real non-Git CLI exposes a lowercase YAML file without a command-specific rule."),
+
+ // ---- Issue #63: Invoke-Expression command-string recursion ----
+ E("iex_full_name_static", "Invoke-Expression 'Get-Date'",
+ "The full cmdlet name recurses into one static literal payload."),
+ E("iex_command_parameter_static", "Invoke-Expression -Command 'Get-Process'",
+ "The exact -Command parameter binds one static payload."),
+ E("iex_variable_dynamic", "Invoke-Expression $code",
+ "A variable payload remains an Invoke-Expression clause with one DynamicSkip arg."),
+ E("iex_interpolated_dynamic", "iex \"Remove-$noun C:\\x\"",
+ "An interpolated payload remains opaque and dynamic."),
+ E("iex_concatenated_dynamic", "iex ('Get-' + 'Date')",
+ "Literal concatenation is not evaluated and collapses to one DynamicSkip arg."),
+ Oos("iex_pipeline_dynamic", "Get-Content script.ps1 | Invoke-Expression",
+ "Pipeline-fed expression code is valid PowerShell but safe-fails as unparseable."),
+ E("iex_inherits_location", "Set-Location C:\\a; iex 'Remove-Item child.txt'",
+ "A static payload inherits the caller's effective location."),
+ E("iex_exports_location", "iex 'Set-Location C:\\b'; Remove-Item child.txt",
+ "A location change inside iex affects following outer clauses."),
+ Oos("iex_recursion_depth_overflow", NestIex("Get-Date", 6),
+ "Six nested static expression strings exceed the shared depth-five cap."),
+ E("iex_quoted_alias_dynamic", "& 'iex' $code",
+ "A quoted alias invoked through the call operator still safe-fails its payload."),
+ E("iex_module_qualified_dynamic", "Microsoft.PowerShell.Utility\\Invoke-Expression $code",
+ "A module-qualified cmdlet name still receives expression security handling."),
+ E("iex_unicode_interpolation_dynamic", "iex \"Remove-$é C:\\x\"",
+ "Unicode variable interpolation cannot hide a clean inner verb."),
+ E("iex_comma_array_dynamic", "Invoke-Expression Write-Output,OTHER",
+ "An unquoted comma array is computed rather than one static scalar string."),
+ E("iex_dynamic_location", "Set-Location C:\\safe; iex $code; Remove-Item child.txt",
+ "Dynamic current-scope code invalidates location attribution for following paths."),
+ E("dynamic_interpolated_iex_name", "& \"i$part\" $code",
+ "An interpolated call-operator command name is dynamic and cannot bypass iex handling."),
+ E("iex_unicode_escaped_alias", "& \"i`u{65}x\" $code",
+ "A Unicode-escaped iex name is decoded before command identity checks."),
+ E("iex_bare_backtick_newline", "iex Write-Output` harmless`nGet-Date",
+ "A backtick newline escape in a bare static payload surfaces both commands."),
+ E("iex_herestring_backtick_newline", "iex @\"\nWrite-Output ok`nGet-Date\n\"@",
+ "An expandable here-string decodes its backtick newline before recursion."),
+ E("iex_backtick_location", "Set-Location C:\\safe; iex Write-Output` ok`nSet-Location` C:\\evil; Remove-Item child.txt",
+ "A hidden escaped-newline location change is surfaced and propagated."),
+ E("iex_command_colon_inline", "iex -Command:Get-Date",
+ "The exact colon-form -Command parameter binds an inline static payload."),
+ E("iex_command_colon_quoted", "iex -Command:'Get-Date'",
+ "An empty colon-form parameter tail binds the following quoted payload."),
+ E("iex_command_colon_dynamic", "iex -Command:$code",
+ "A dynamic inline colon-form payload remains one DynamicSkip arg."),
+ E("iex_vertical_tab_path", "iex \"Remove-Item`vC:\\x\"",
+ "Decoded vertical-tab whitespace separates the inner path argument."),
+ E("iex_form_feed_path", "iex \"Remove-Item`fC:\\x\"",
+ "Decoded form-feed whitespace separates the inner path argument."),
+ E("iex_unicode_whitespace_path", "iex \"Remove-Item`u{2003}C:\\x\"",
+ "Decoded Unicode whitespace separates the inner path argument."),
+ E("iex_vertical_tab_location", "Set-Location C:\\safe; iex \"Set-Location`vC:\\evil\"; Remove-Item child.txt",
+ "Decoded vertical-tab whitespace preserves an inner location change."),
+ E("iex_command_colon_comment", "iex -Command:#comment",
+ "A comment after an empty colon value leaves -Command without a payload."),
+ E("iex_command_colon_backtick_newline", "iex -Command:Write-Output`nGet-Date",
+ "An inline colon payload decodes backtick newline before recursion."),
+ Oos("iex_decoded_nul", "iex \"Remove-Item`0C:\\x\"",
+ "A decoded NUL safe-fails instead of merging a hidden path into the verb."),
+ E("iex_command_colon_unicode_escape", "iex -Command:Get-`u{44}ate",
+ "An inline colon payload consumes and decodes a complete Unicode escape."),
+ Oos("iex_dot_invocation", ". iex 'Remove-Item C:\\x'",
+ "Dot invocation is valid PowerShell but outside the parser grammar and safe-fails."),
+ E("iex_scoped_interpolation_dynamic", "iex \"Remove-$:noun C:\\x\"",
+ "Scoped interpolation syntax keeps the expression payload dynamic."),
+ Oos("iex_module_qualified_remove", "iex 'Microsoft.PowerShell.Management\\Remove-Item C:\\x'",
+ "An unsupported module-qualified inner cmdlet safe-fails instead of hiding its identity."),
+ Oos("iex_module_qualified_location", "Set-Location C:\\safe; iex 'Microsoft.PowerShell.Management\\Set-Location C:\\evil'; Remove-Item child.txt",
+ "An unsupported module-qualified location mutation safe-fails instead of preserving stale cwd."),
+ E("dynamic_command_location", "Set-Location C:\\safe; & \"i$part\" $code; Get-Item child.txt",
+ "Any dynamic command invalidates following current-scope location attribution."),
+ E("quoted_expression_command", "\"iex\" 'Get-Date'",
+ "A quoted expression without the call operator is not a command invocation."),
+ Oos("iex_quoted_inner_expression", "Set-Location C:\\safe; iex '\"Set-Location\" C:\\evil'; Get-Item child.txt",
+ "A quoted inner expression is valid outer syntax but not a supported command invocation."),
+ E("iex_escape_character", "Set-Location C:\\safe; iex \"S`et-Location C:\\evil\"; Get-Item child.txt",
+ "The backtick e escape becomes ESC and cannot spoof Set-Location."),
+ E("iex_invalid_unicode_empty", "iex \"Get-`u{}Date\"",
+ "An empty Unicode escape is a PowerShell syntax error."),
+ E("iex_invalid_unicode_range", "iex \"Get-`u{110000}Date\"",
+ "An out-of-range Unicode escape is a PowerShell syntax error."),
+ E("iex_invalid_unicode_colon", "iex -Command:Get-`u{}Date",
+ "Malformed Unicode syntax in an inline colon payload safe-fails."),
+ Oos("iex_quoted_module_remove", "iex \"& 'Microsoft.PowerShell.Management\\Remove-Item' C:\\x\"",
+ "A quoted module-qualified inner cmdlet safe-fails under the call operator."),
+ Oos("iex_quoted_module_location", "Set-Location C:\\safe; iex \"& 'Microsoft.PowerShell.Management\\Set-Location' C:\\evil\"; Remove-Item child.txt",
+ "A quoted module-qualified location mutation cannot preserve stale cwd."),
};
}