From 787b294a69755e8531c44c883935372cbdbe296b Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Tue, 4 Aug 2026 13:53:56 -0500 Subject: [PATCH 1/4] docs: plan Invoke-Expression recursion --- .../.openspec.yaml | 2 + .../design.md | 138 +++++++++++++++ .../proposal.md | 38 +++++ .../specs/invoke-expression-recursion/spec.md | 157 ++++++++++++++++++ .../recurse-static-invoke-expression/tasks.md | 32 ++++ 5 files changed, 367 insertions(+) create mode 100644 openspec/changes/recurse-static-invoke-expression/.openspec.yaml create mode 100644 openspec/changes/recurse-static-invoke-expression/design.md create mode 100644 openspec/changes/recurse-static-invoke-expression/proposal.md create mode 100644 openspec/changes/recurse-static-invoke-expression/specs/invoke-expression-recursion/spec.md create mode 100644 openspec/changes/recurse-static-invoke-expression/tasks.md 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..bdaca92 --- /dev/null +++ b/openspec/changes/recurse-static-invoke-expression/design.md @@ -0,0 +1,138 @@ +## 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. + +### 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. + +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..ab85cd6 --- /dev/null +++ b/openspec/changes/recurse-static-invoke-expression/specs/invoke-expression-recursion/spec.md @@ -0,0 +1,157 @@ +## 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: 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 + +### 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` + +### 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..dcaca15 --- /dev/null +++ b/openspec/changes/recurse-static-invoke-expression/tasks.md @@ -0,0 +1,32 @@ +## 1. Contract + +- [ ] 1.1 Replace the `Invoke-Expression` exclusion in `SPEC.POWERSHELL.md` section 10 with static recursion and dynamic safe-fail rules. +- [ ] 1.2 Specify current-scope location propagation, shared recursion failures, and corpus coverage in `SPEC.POWERSHELL.md` sections 10, 11, and 13. +- [ ] 1.3 Update the shared command-string wrapper documentation in `SPEC.md` without changing the public API. + +## 2. Lexical Evidence + +- [ ] 2.1 Record unescaped interpolation on double-quoted strings and expandable here-strings. +- [ ] 2.2 Add lexer tests for variable interpolation, subexpression interpolation, escaped dollar signs, and static expandable strings. + +## 3. Parser + +- [ ] 3.1 Recognize `Invoke-Expression` and `iex` through one canonical command identity. +- [ ] 3.2 Recurse into one provably static positional or `-Command` payload and surface wrapped inner clauses. +- [ ] 3.3 Collapse direct computed payloads into one `DynamicSkip` source argument. +- [ ] 3.4 Mark pipeline-only, incoming-pipeline, missing, and ambiguously bound payloads unparseable. +- [ ] 3.5 Share the existing input cap and depth-five counter across expression, command, and encoded-command recursion. +- [ ] 3.6 Share location context with expression recursion so inner location changes affect following outer clauses while child `pwsh` remains isolated. + +## 4. Verification + +- [ ] 4.1 Add parser unit tests for full-name, alias, literal, parameter, compound, operator, and wrapper cases. +- [ ] 4.2 Add parser unit tests for interpolation, variables, concatenation, subexpressions, pipeline input, missing payload, and inner anomalies. +- [ ] 4.3 Add parser unit tests for inherited location, exported location changes, child-process isolation, depth limits, mixed wrappers, and input limits. +- [ ] 4.4 Replace PowerShell corpus case 157 and add the required static, dynamic, pipeline, location, and recursion cases to `CorpusManifest`. +- [ ] 4.5 Regenerate the PowerShell corpus and confirm the real-`pwsh` oracle matrix remains valid. + +## 5. Completion + +- [ ] 5.1 Update `RELEASE_NOTES.md` and `IMPLEMENTATION_PLAN.md` for issue #63. +- [ ] 5.2 Run OpenSpec validation, Release build, full tests, and copyright-header verification. From 3e1a1260ab782eecc560f9a032011f85e61dfb47 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Tue, 4 Aug 2026 14:46:11 -0500 Subject: [PATCH 2/4] feat(pwsh): recurse into static Invoke-Expression --- IMPLEMENTATION_PLAN.md | 4 + README.md | 2 + RELEASE_NOTES.md | 12 + SPEC.POWERSHELL.md | 78 ++++-- SPEC.md | 6 +- .../design.md | 4 + .../specs/invoke-expression-recursion/spec.md | 5 + .../recurse-static-invoke-expression/tasks.md | 36 +-- src/ShellSyntaxTree/Clause.cs | 3 +- .../Internal/Pwsh/Lexing/PwshLexer.cs | 58 +++- .../Internal/Pwsh/Lexing/PwshToken.cs | 7 + .../Pwsh/Parsing/PwshCommandParser.cs | 253 +++++++++++++++++- src/ShellSyntaxTree/VerbChain.cs | 5 +- ...sed.json => 157_recursion_iex_static.json} | 14 +- .../powershell/220_iex_full_name_static.json | 19 ++ .../221_iex_command_parameter_static.json | 19 ++ .../powershell/222_iex_variable_dynamic.json | 24 ++ .../223_iex_interpolated_dynamic.json | 25 ++ .../224_iex_concatenated_dynamic.json | 25 ++ .../powershell/225_iex_pipeline_dynamic.json | 10 + .../powershell/226_iex_inherits_location.json | 48 ++++ .../powershell/227_iex_exports_location.json | 48 ++++ .../228_iex_recursion_depth_overflow.json | 10 + .../229_iex_quoted_alias_dynamic.json | 25 ++ .../230_iex_module_qualified_dynamic.json | 24 ++ ...231_iex_unicode_interpolation_dynamic.json | 25 ++ .../232_iex_comma_array_dynamic.json | 24 ++ .../powershell/233_iex_dynamic_location.json | 67 +++++ .../234_dynamic_interpolated_iex_name.json | 25 ++ .../Lexing/PwshLexerTests.cs | 43 +++ .../Parsing/PwshCommandParserTests.cs | 237 ++++++++++++++++ tools/PwshCorpusTool/CorpusManifest.cs | 46 +++- 32 files changed, 1160 insertions(+), 71 deletions(-) rename tests/ShellSyntaxTree.Tests/Corpus/powershell/{157_recursion_iex_not_recursed.json => 157_recursion_iex_static.json} (50%) create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/220_iex_full_name_static.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/221_iex_command_parameter_static.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/222_iex_variable_dynamic.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/223_iex_interpolated_dynamic.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/224_iex_concatenated_dynamic.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/225_iex_pipeline_dynamic.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/226_iex_inherits_location.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/227_iex_exports_location.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/228_iex_recursion_depth_overflow.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/229_iex_quoted_alias_dynamic.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/230_iex_module_qualified_dynamic.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/231_iex_unicode_interpolation_dynamic.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/232_iex_comma_array_dynamic.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/233_iex_dynamic_location.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/234_dynamic_interpolated_iex_name.json diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index a91fef6..b2f964b 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 373d1e7..2a72d4c 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,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..76d1275 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,15 @@ +#### 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. + #### 0.2.0-beta.1 2026-07-22 #### ## Fixed diff --git a/SPEC.POWERSHELL.md b/SPEC.POWERSHELL.md index f2f4737..6ed697c 100644 --- a/SPEC.POWERSHELL.md +++ b/SPEC.POWERSHELL.md @@ -46,9 +46,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 @@ -179,7 +180,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 @@ -191,7 +193,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). --- @@ -803,7 +805,7 @@ pipeline elements within a statement, not separate statements. --- -## 10. Subexpression & `pwsh -Command` Recursion +## 10. Subexpression & Command-String Recursion ### Opaque regions @@ -878,11 +880,39 @@ 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. + +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. --- @@ -913,10 +943,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 @@ -933,8 +966,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). @@ -1058,9 +1091,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 @@ -1075,7 +1108,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 | @@ -1180,7 +1213,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/design.md b/openspec/changes/recurse-static-invoke-expression/design.md index bdaca92..a6c86ab 100644 --- a/openspec/changes/recurse-static-invoke-expression/design.md +++ b/openspec/changes/recurse-static-invoke-expression/design.md @@ -71,6 +71,10 @@ 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. 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 index ab85cd6..270edd7 100644 --- 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 @@ -126,6 +126,11 @@ context. - **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. diff --git a/openspec/changes/recurse-static-invoke-expression/tasks.md b/openspec/changes/recurse-static-invoke-expression/tasks.md index dcaca15..3245b79 100644 --- a/openspec/changes/recurse-static-invoke-expression/tasks.md +++ b/openspec/changes/recurse-static-invoke-expression/tasks.md @@ -1,32 +1,32 @@ ## 1. Contract -- [ ] 1.1 Replace the `Invoke-Expression` exclusion in `SPEC.POWERSHELL.md` section 10 with static recursion and dynamic safe-fail rules. -- [ ] 1.2 Specify current-scope location propagation, shared recursion failures, and corpus coverage in `SPEC.POWERSHELL.md` sections 10, 11, and 13. -- [ ] 1.3 Update the shared command-string wrapper documentation in `SPEC.md` without changing the public API. +- [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 -- [ ] 2.1 Record unescaped interpolation on double-quoted strings and expandable here-strings. -- [ ] 2.2 Add lexer tests for variable interpolation, subexpression interpolation, escaped dollar signs, and static expandable strings. +- [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 -- [ ] 3.1 Recognize `Invoke-Expression` and `iex` through one canonical command identity. -- [ ] 3.2 Recurse into one provably static positional or `-Command` payload and surface wrapped inner clauses. -- [ ] 3.3 Collapse direct computed payloads into one `DynamicSkip` source argument. -- [ ] 3.4 Mark pipeline-only, incoming-pipeline, missing, and ambiguously bound payloads unparseable. -- [ ] 3.5 Share the existing input cap and depth-five counter across expression, command, and encoded-command recursion. -- [ ] 3.6 Share location context with expression recursion so inner location changes affect following outer clauses while child `pwsh` remains isolated. +- [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 -- [ ] 4.1 Add parser unit tests for full-name, alias, literal, parameter, compound, operator, and wrapper cases. -- [ ] 4.2 Add parser unit tests for interpolation, variables, concatenation, subexpressions, pipeline input, missing payload, and inner anomalies. -- [ ] 4.3 Add parser unit tests for inherited location, exported location changes, child-process isolation, depth limits, mixed wrappers, and input limits. -- [ ] 4.4 Replace PowerShell corpus case 157 and add the required static, dynamic, pipeline, location, and recursion cases to `CorpusManifest`. -- [ ] 4.5 Regenerate the PowerShell corpus and confirm the real-`pwsh` oracle matrix remains valid. +- [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. ## 5. Completion -- [ ] 5.1 Update `RELEASE_NOTES.md` and `IMPLEMENTATION_PLAN.md` for issue #63. -- [ ] 5.2 Run OpenSpec validation, Release build, full tests, and copyright-header verification. +- [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..64d9492 100644 --- a/src/ShellSyntaxTree/Internal/Pwsh/Lexing/PwshLexer.cs +++ b/src/ShellSyntaxTree/Internal/Pwsh/Lexing/PwshLexer.cs @@ -349,6 +349,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,7 +366,8 @@ 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; } @@ -388,6 +390,11 @@ private static int ReadDoubleQuoted( continue; } + if (c == '$' && StartsInterpolation(src, i)) + { + hasInterpolation = true; + } + sb.Append(c); i++; } @@ -508,11 +515,17 @@ private static int TryReadHereString( var body = bodyEnd >= bodyStart ? src.Slice(bodyStart, bodyEnd - bodyStart).ToString() : string.Empty; + var hasInterpolation = quote == '"' + && ContainsInterpolation(src.Slice(bodyStart, bodyEnd - bodyStart)); 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 +539,37 @@ private static int TryReadHereString( return src.Length; } + private static bool ContainsInterpolation(ReadOnlySpan value) + { + for (var i = 0; i < value.Length; i++) + { + if (value[i] == '`' && i + 1 < value.Length) + { + i++; + continue; + } + + if (value[i] == '$' && StartsInterpolation(value, i)) + { + return true; + } + } + + return false; + } + + 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 '_' + || char.IsLetterOrDigit(next); + } + // ---------------------------------------------------------------- regions /// @@ -700,6 +744,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) { @@ -740,6 +785,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 +807,11 @@ private static int ReadWord( continue; } + if (c == '$' && StartsInterpolation(src, i)) + { + hasInterpolation = true; + } + sb.Append(c); i++; } @@ -772,7 +823,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; } 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..06684b4 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); @@ -376,6 +379,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 +395,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 +433,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 +481,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 +501,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 +547,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; @@ -527,6 +573,13 @@ private static BuildResult BuildSegment( // 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( @@ -614,7 +667,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 +1118,160 @@ 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; + if (payloadStart >= body.Count) + { + result = BuildResult.Fail("Invoke-Expression is missing its payload"); + return true; + } + + if (body[payloadStart].Kind == PwshTokenKind.Parameter) + { + if (!string.Equals( + body[payloadStart].Value, "-Command", StringComparison.OrdinalIgnoreCase)) + { + result = BuildResult.Fail( + "Invoke-Expression payload binding is ambiguous"); + return true; + } + + payloadStart++; + if (payloadStart >= body.Count) + { + result = BuildResult.Fail("Invoke-Expression is missing its payload"); + return true; + } + } + + for (var i = payloadStart; 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 singlePayload = payloadStart == payloadEnd; + var payload = body[payloadStart]; + var isStatic = singlePayload + && payload.Kind is PwshTokenKind.Word or PwshTokenKind.QuotedString + && !payload.HasInterpolation + && (payload.Kind != PwshTokenKind.Word + || !PwshResolver.LooksLikeCommaArray(payload.Value)); + + if (!isStatic) + { + var rawPayload = SourceSlice(source, body[payloadStart], body[payloadEnd]); + 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( + payload.Value, 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 +1341,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 +1590,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/Lexing/PwshLexerTests.cs b/tests/ShellSyntaxTree.Tests/Lexing/PwshLexerTests.cs index 256a766..e283575 100644 --- a/tests/ShellSyntaxTree.Tests/Lexing/PwshLexerTests.cs +++ b/tests/ShellSyntaxTree.Tests/Lexing/PwshLexerTests.cs @@ -63,6 +63,49 @@ 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-$é\"")] + 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_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] diff --git a/tests/ShellSyntaxTree.Tests/Parsing/PwshCommandParserTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/PwshCommandParserTests.cs index a3ebe4e..edeb587 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,241 @@ 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); + } + } + + [Theory] + [InlineData("OtherModule\\Invoke-Expression $code")] + [InlineData("OtherModule\\iex $code")] + public void Custom_module_commands_are_not_treated_as_invoke_expression( + string input) + { + 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); + } + + [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 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..bdec148 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,37 @@ 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."), }; } From 477e50e40dbbea7a95400cf6cac628d97f117ee3 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Tue, 4 Aug 2026 15:11:28 -0500 Subject: [PATCH 3/4] fix(pwsh): harden Invoke-Expression escape handling --- RELEASE_NOTES.md | 3 +- SPEC.POWERSHELL.md | 25 ++- .../design.md | 5 + .../specs/invoke-expression-recursion/spec.md | 26 +++ .../recurse-static-invoke-expression/tasks.md | 4 + .../Internal/Pwsh/Lexing/PwshLexer.cs | 160 ++++++++++++++---- .../Pwsh/Parsing/PwshCommandParser.cs | 59 +++++-- .../235_iex_unicode_escaped_alias.json | 25 +++ .../236_iex_bare_backtick_newline.json | 34 ++++ .../237_iex_herestring_backtick_newline.json | 34 ++++ .../powershell/238_iex_backtick_location.json | 92 ++++++++++ .../239_iex_command_colon_inline.json | 19 +++ .../240_iex_command_colon_quoted.json | 19 +++ .../241_iex_command_colon_dynamic.json | 25 +++ .../powershell/242_iex_vertical_tab_path.json | 26 +++ .../powershell/243_iex_form_feed_path.json | 26 +++ .../244_iex_unicode_whitespace_path.json | 26 +++ .../245_iex_vertical_tab_location.json | 70 ++++++++ .../246_iex_command_colon_comment.json | 9 + ...47_iex_command_colon_backtick_newline.json | 28 +++ .../powershell/248_iex_decoded_nul.json | 10 ++ .../249_iex_command_colon_unicode_escape.json | 19 +++ .../Lexing/PwshLexerTests.cs | 63 +++++++ .../Parsing/PwshCommandParserTests.cs | 112 ++++++++++++ tools/PwshCorpusTool/CorpusManifest.cs | 30 ++++ 25 files changed, 899 insertions(+), 50 deletions(-) create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/235_iex_unicode_escaped_alias.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/236_iex_bare_backtick_newline.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/237_iex_herestring_backtick_newline.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/238_iex_backtick_location.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/239_iex_command_colon_inline.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/240_iex_command_colon_quoted.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/241_iex_command_colon_dynamic.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/242_iex_vertical_tab_path.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/243_iex_form_feed_path.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/244_iex_unicode_whitespace_path.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/245_iex_vertical_tab_location.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/246_iex_command_colon_comment.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/247_iex_command_colon_backtick_newline.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/248_iex_decoded_nul.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/249_iex_command_colon_unicode_escape.json diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 76d1275..2f2f88a 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -8,7 +8,8 @@ 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. + 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 #### diff --git a/SPEC.POWERSHELL.md b/SPEC.POWERSHELL.md index 6ed697c..96b8b87 100644 --- a/SPEC.POWERSHELL.md +++ b/SPEC.POWERSHELL.md @@ -320,19 +320,29 @@ 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. 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 @@ -887,7 +897,12 @@ Static call-operator spellings such as `& 'iex' ...` and module-qualified 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. +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 diff --git a/openspec/changes/recurse-static-invoke-expression/design.md b/openspec/changes/recurse-static-invoke-expression/design.md index a6c86ab..77cab97 100644 --- a/openspec/changes/recurse-static-invoke-expression/design.md +++ b/openspec/changes/recurse-static-invoke-expression/design.md @@ -63,6 +63,11 @@ 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 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 index 270edd7..da24b9a 100644 --- 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 @@ -30,6 +30,10 @@ the outer clause's preceding operator on the first surfaced clause. - **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 @@ -101,6 +105,28 @@ The parser SHALL use that lexical evidence when proving an - **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` + ### 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. diff --git a/openspec/changes/recurse-static-invoke-expression/tasks.md b/openspec/changes/recurse-static-invoke-expression/tasks.md index 3245b79..fc50a5d 100644 --- a/openspec/changes/recurse-static-invoke-expression/tasks.md +++ b/openspec/changes/recurse-static-invoke-expression/tasks.md @@ -25,6 +25,10 @@ - [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. ## 5. Completion diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Lexing/PwshLexer.cs b/src/ShellSyntaxTree/Internal/Pwsh/Lexing/PwshLexer.cs index 64d9492..91dd844 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++; } @@ -373,20 +382,7 @@ private static int ReadDoubleQuoted( if (c == '`' && i + 1 < src.Length) { - var n = src[i + 1]; - sb.Append(n switch - { - 'n' => '\n', - 't' => '\t', - 'r' => '\r', - '0' => '\0', - 'a' => '\a', - 'b' => '\b', - 'f' => '\f', - 'v' => '\v', - _ => n, - }); - i += 2; + i = AppendBacktickEscape(src, i, sb); continue; } @@ -512,11 +508,13 @@ private static int TryReadHereString( bodyEnd--; } - var body = bodyEnd >= bodyStart - ? src.Slice(bodyStart, bodyEnd - bodyStart).ToString() - : string.Empty; - var hasInterpolation = quote == '"' - && ContainsInterpolation(src.Slice(bodyStart, bodyEnd - bodyStart)); + var bodySpan = bodyEnd >= bodyStart + ? src.Slice(bodyStart, bodyEnd - bodyStart) + : ReadOnlySpan.Empty; + var hasInterpolation = false; + var body = quote == '"' + ? DecodeExpandableString(bodySpan, out hasInterpolation) + : bodySpan.ToString(); var end = k + 2; // past quote + '@' tokens.Add(new PwshToken( PwshTokenKind.QuotedString, body, null, @@ -539,25 +537,34 @@ private static int TryReadHereString( return src.Length; } - private static bool ContainsInterpolation(ReadOnlySpan value) + private static string DecodeExpandableString( + ReadOnlySpan value, out bool hasInterpolation) { + var decoded = new StringBuilder(value.Length); + hasInterpolation = false; for (var i = 0; i < value.Length; i++) { if (value[i] == '`' && i + 1 < value.Length) { - i++; + i = AppendBacktickEscape(value, i, decoded) - 1; continue; } if (value[i] == '$' && StartsInterpolation(value, i)) { - return true; + hasInterpolation = true; } + + decoded.Append(value[i]); } - return false; + return decoded.ToString(); } + internal static string DecodeExpandableValue( + string value, out bool hasInterpolation) => + DecodeExpandableString(value.AsSpan(), out hasInterpolation); + private static bool StartsInterpolation(ReadOnlySpan value, int dollarIndex) { if (dollarIndex + 1 >= value.Length) @@ -570,6 +577,87 @@ private static bool StartsInterpolation(ReadOnlySpan value, int dollarInde || 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)) + { + target.Append(char.ConvertFromUtf32(scalar)); + return endIndex; + } + + target.Append(escaped switch + { + 'n' => '\n', + 't' => '\t', + 'r' => '\r', + '0' => '\0', + 'a' => '\a', + 'b' => '\b', + '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 >= 0xD800 && scalar <= 0xDFFF)) + { + scalar = 0; + return false; + } + + endIndex = i + 1; + return true; + } + + 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 /// @@ -765,14 +853,13 @@ 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; + i = AppendBacktickEscape(src, i, sb); continue; } @@ -835,10 +922,11 @@ private static int ReadWord( /// private static int ScanWordRun(ReadOnlySpan src, int i) { + var start = i; while (i < src.Length) { var c = src[i]; - if (IsWordBoundary(c)) + if (IsWordBoundary(c) || (i == start && c == '#')) { break; } @@ -856,6 +944,13 @@ private static int ScanWordRun(ReadOnlySpan src, int i) break; } + if (src[i + 1] == 'u' && TryReadUnicodeEscape( + src, i, out _, out var unicodeEnd)) + { + i = unicodeEnd; + continue; + } + i += 2; continue; } @@ -903,7 +998,7 @@ private static bool IsWordBoundary(char c) case '>': return true; default: - return false; + return c == '\0' || IsInlineWhitespace(c); } } @@ -912,6 +1007,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/Parsing/PwshCommandParser.cs b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshCommandParser.cs index 06684b4..1e899a8 100644 --- a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshCommandParser.cs +++ b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshCommandParser.cs @@ -1137,6 +1137,7 @@ private static bool TryHandleInvokeExpression( } var payloadStart = start + 1; + string? inlinePayload = null; if (payloadStart >= body.Count) { result = BuildResult.Fail("Invoke-Expression is missing its payload"); @@ -1145,23 +1146,40 @@ private static bool TryHandleInvokeExpression( 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( - body[payloadStart].Value, "-Command", StringComparison.OrdinalIgnoreCase)) + 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 (payloadStart >= body.Count) + if (inlinePayload is null && payloadStart >= body.Count) { result = BuildResult.Fail("Invoke-Expression is missing its payload"); return true; } } - for (var i = payloadStart; i < body.Count; i++) + for (var i = payloadStart; inlinePayload is null && i < body.Count; i++) { if (body[i].Kind == PwshTokenKind.Operator && body[i].OperatorText is not "(" and not ")") @@ -1173,17 +1191,32 @@ private static bool TryHandleInvokeExpression( } var payloadEnd = body.Count - 1; - var singlePayload = payloadStart == payloadEnd; - var payload = body[payloadStart]; - var isStatic = singlePayload - && payload.Kind is PwshTokenKind.Word or PwshTokenKind.QuotedString - && !payload.HasInterpolation - && (payload.Kind != PwshTokenKind.Word - || !PwshResolver.LooksLikeCommaArray(payload.Value)); + var payloadValue = inlinePayload; + var rawPayload = inlinePayload; + var isStatic = false; + if (inlinePayload is not null) + { + payloadValue = PwshLexer.DecodeExpandableValue( + inlinePayload!, out var hasInterpolation); + 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 rawPayload = SourceSlice(source, body[payloadStart], body[payloadEnd]); var canonicalVerb = verb.CanonicalVerb; if (canonicalVerb is null && verb.VerbTokens.Count > 0 && string.Equals( @@ -1205,7 +1238,7 @@ private static bool TryHandleInvokeExpression( { new Arg { - Raw = rawPayload, + Raw = rawPayload!, Kind = ArgKind.DynamicSkip, IsPath = false, }, @@ -1231,7 +1264,7 @@ private static bool TryHandleInvokeExpression( } var innerParsed = ParseInternal( - payload.Value, options, recursionDepth + 1, markWrapped: true, + payloadValue!, options, recursionDepth + 1, markWrapped: true, sharedLocation: attribution); if (innerParsed.IsUnparseable) { 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/Lexing/PwshLexerTests.cs b/tests/ShellSyntaxTree.Tests/Lexing/PwshLexerTests.cs index e283575..11d8af8 100644 --- a/tests/ShellSyntaxTree.Tests/Lexing/PwshLexerTests.cs +++ b/tests/ShellSyntaxTree.Tests/Lexing/PwshLexerTests.cs @@ -94,6 +94,13 @@ public void Expandable_here_string_records_interpolation() 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() { @@ -115,6 +122,46 @@ 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 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() { @@ -140,6 +187,22 @@ 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); + } + [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 edeb587..1ed5281 100644 --- a/tests/ShellSyntaxTree.Tests/Parsing/PwshCommandParserTests.cs +++ b/tests/ShellSyntaxTree.Tests/Parsing/PwshCommandParserTests.cs @@ -665,6 +665,118 @@ public void Alternate_invoke_expression_names_still_safe_fail_dynamic_payloads( } } + [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); + } + [Theory] [InlineData("OtherModule\\Invoke-Expression $code")] [InlineData("OtherModule\\iex $code")] diff --git a/tools/PwshCorpusTool/CorpusManifest.cs b/tools/PwshCorpusTool/CorpusManifest.cs index bdec148..a985e73 100644 --- a/tools/PwshCorpusTool/CorpusManifest.cs +++ b/tools/PwshCorpusTool/CorpusManifest.cs @@ -524,5 +524,35 @@ private static string NestIex(string inner, int depth) "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."), }; } From f98c6873cac036ba1d26954548801a4dbb198be1 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Tue, 4 Aug 2026 15:47:27 -0500 Subject: [PATCH 4/4] fix(pwsh): close Invoke-Expression bypasses --- SPEC.POWERSHELL.md | 14 ++- .../specs/invoke-expression-recursion/spec.md | 16 +++ .../recurse-static-invoke-expression/tasks.md | 2 + .../Internal/Pwsh/Lexing/PwshLexer.cs | 90 ++++++++++++++-- .../Pwsh/Parsing/PwshCommandParser.cs | 102 ++++++++++++++++-- .../powershell/250_iex_dot_invocation.json | 10 ++ .../251_iex_scoped_interpolation_dynamic.json | 25 +++++ .../252_iex_module_qualified_remove.json | 10 ++ .../253_iex_module_qualified_location.json | 10 ++ .../254_dynamic_command_location.json | 67 ++++++++++++ .../255_quoted_expression_command.json | 9 ++ .../256_iex_quoted_inner_expression.json | 10 ++ .../powershell/257_iex_escape_character.json | 70 ++++++++++++ .../258_iex_invalid_unicode_empty.json | 9 ++ .../259_iex_invalid_unicode_range.json | 9 ++ .../260_iex_invalid_unicode_colon.json | 9 ++ .../261_iex_quoted_module_remove.json | 10 ++ .../262_iex_quoted_module_location.json | 10 ++ .../Lexing/PwshLexerTests.cs | 32 ++++++ .../Parsing/PwshCommandParserTests.cs | 85 ++++++++++++++- tools/PwshCorpusTool/CorpusManifest.cs | 26 +++++ 21 files changed, 603 insertions(+), 22 deletions(-) create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/250_iex_dot_invocation.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/251_iex_scoped_interpolation_dynamic.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/252_iex_module_qualified_remove.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/253_iex_module_qualified_location.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/254_dynamic_command_location.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/255_quoted_expression_command.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/256_iex_quoted_inner_expression.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/257_iex_escape_character.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/258_iex_invalid_unicode_empty.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/259_iex_invalid_unicode_range.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/260_iex_invalid_unicode_colon.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/261_iex_quoted_module_remove.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/262_iex_quoted_module_location.json diff --git a/SPEC.POWERSHELL.md b/SPEC.POWERSHELL.md index 96b8b87..f6e4638 100644 --- a/SPEC.POWERSHELL.md +++ b/SPEC.POWERSHELL.md @@ -337,7 +337,10 @@ The `PwshLexer` produces tokens consumed by `PwshCommandParser`. Token kinds 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. Decoding occurs before static command-string recursion so an + 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 @@ -929,6 +932,15 @@ 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`. + --- ## 11. Parser Anomaly Behavior 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 index da24b9a..7f74be2 100644 --- 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 @@ -127,6 +127,22 @@ The parser SHALL use that lexical evidence when proving an - **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. diff --git a/openspec/changes/recurse-static-invoke-expression/tasks.md b/openspec/changes/recurse-static-invoke-expression/tasks.md index fc50a5d..8117bef 100644 --- a/openspec/changes/recurse-static-invoke-expression/tasks.md +++ b/openspec/changes/recurse-static-invoke-expression/tasks.md @@ -29,6 +29,8 @@ - [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 diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Lexing/PwshLexer.cs b/src/ShellSyntaxTree/Internal/Pwsh/Lexing/PwshLexer.cs index 91dd844..878e889 100644 --- a/src/ShellSyntaxTree/Internal/Pwsh/Lexing/PwshLexer.cs +++ b/src/ShellSyntaxTree/Internal/Pwsh/Lexing/PwshLexer.cs @@ -382,6 +382,12 @@ private static int ReadDoubleQuoted( if (c == '`' && i + 1 < src.Length) { + if (IsMalformedUnicodeEscape(src, i)) + { + tokens.Add(InvalidUnicodeEscapeToken(src, i)); + return src.Length; + } + i = AppendBacktickEscape(src, i, sb); continue; } @@ -512,9 +518,18 @@ private static int TryReadHereString( ? src.Slice(bodyStart, bodyEnd - bodyStart) : ReadOnlySpan.Empty; var hasInterpolation = false; + var invalidUnicodeAt = -1; var body = quote == '"' - ? DecodeExpandableString(bodySpan, out hasInterpolation) + ? 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, @@ -538,14 +553,21 @@ private static int TryReadHereString( } private static string DecodeExpandableString( - ReadOnlySpan value, out bool hasInterpolation) + 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; } @@ -561,9 +583,13 @@ private static string DecodeExpandableString( return decoded.ToString(); } - internal static string DecodeExpandableValue( - string value, out bool hasInterpolation) => - DecodeExpandableString(value.AsSpan(), out hasInterpolation); + 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) { @@ -573,7 +599,7 @@ private static bool StartsInterpolation(ReadOnlySpan value, int dollarInde } var next = value[dollarIndex + 1]; - return next is '(' or '{' or '?' or '^' or '$' or '_' + return next is '(' or '{' or '?' or '^' or '$' or '_' or ':' || char.IsLetterOrDigit(next); } @@ -584,7 +610,15 @@ private static int AppendBacktickEscape( if (escaped == 'u' && TryReadUnicodeEscape( value, backtickIndex, out var scalar, out var endIndex)) { - target.Append(char.ConvertFromUtf32(scalar)); + if (scalar <= char.MaxValue) + { + target.Append((char)scalar); + } + else + { + target.Append(char.ConvertFromUtf32(scalar)); + } + return endIndex; } @@ -596,6 +630,7 @@ private static int AppendBacktickEscape( '0' => '\0', 'a' => '\a', 'b' => '\b', + 'e' => '\u001b', 'f' => '\f', 'v' => '\v', _ => escaped, @@ -624,7 +659,7 @@ private static bool TryReadUnicodeEscape( } if (digits == 0 || i >= value.Length || value[i] != '}' - || scalar > 0x10FFFF || (scalar >= 0xD800 && scalar <= 0xDFFF)) + || scalar > 0x10FFFF) { scalar = 0; return false; @@ -634,6 +669,22 @@ private static bool TryReadUnicodeEscape( 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') @@ -817,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( @@ -859,6 +915,12 @@ private static int ReadWord( break; // line continuation — handled by the outer loop } + if (IsMalformedUnicodeEscape(src, i)) + { + tokens.Add(InvalidUnicodeEscapeToken(src, i)); + return src.Length; + } + i = AppendBacktickEscape(src, i, sb); continue; } @@ -920,8 +982,10 @@ 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) { @@ -951,6 +1015,12 @@ private static int ScanWordRun(ReadOnlySpan src, int i) continue; } + if (IsMalformedUnicodeEscape(src, i)) + { + invalidUnicodeAt = i; + return src.Length; + } + i += 2; continue; } diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshCommandParser.cs b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshCommandParser.cs index 1e899a8..b2a0dd8 100644 --- a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshCommandParser.cs +++ b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshCommandParser.cs @@ -191,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)) { @@ -207,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; @@ -562,13 +629,21 @@ 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. @@ -607,7 +682,7 @@ private static BuildResult BuildSegment( IsDynamic = classified.IsDynamic, }; - return BuildResult.Ok(new Clause + var clause = new Clause { Operator = segment.PrecedingOperator, Verb = verb, @@ -615,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 @@ -1196,8 +1280,14 @@ private static bool TryHandleInvokeExpression( var isStatic = false; if (inlinePayload is not null) { - payloadValue = PwshLexer.DecodeExpandableValue( - inlinePayload!, out var hasInterpolation); + 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); } 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 11d8af8..173f35a 100644 --- a/tests/ShellSyntaxTree.Tests/Lexing/PwshLexerTests.cs +++ b/tests/ShellSyntaxTree.Tests/Lexing/PwshLexerTests.cs @@ -70,6 +70,7 @@ public void Double_quoted_string_keeps_var_literal() [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)); @@ -129,6 +130,30 @@ public void Double_quote_decodes_unicode_escape() 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() { @@ -203,6 +228,13 @@ public void Colon_value_scanner_consumes_complete_unicode_escape() 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 1ed5281..5353e06 100644 --- a/tests/ShellSyntaxTree.Tests/Parsing/PwshCommandParserTests.cs +++ b/tests/ShellSyntaxTree.Tests/Parsing/PwshCommandParserTests.cs @@ -777,18 +777,22 @@ public void Invoke_expression_colon_unicode_escape_is_decoded() Assert.True(clause.IsCommandStringWrapped); } - [Theory] - [InlineData("OtherModule\\Invoke-Expression $code")] - [InlineData("OtherModule\\iex $code")] - public void Custom_module_commands_are_not_treated_as_invoke_expression( - string input) + [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")] @@ -798,6 +802,77 @@ public void Interpolated_call_operator_command_name_is_dynamic(string input) 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() { diff --git a/tools/PwshCorpusTool/CorpusManifest.cs b/tools/PwshCorpusTool/CorpusManifest.cs index a985e73..094c534 100644 --- a/tools/PwshCorpusTool/CorpusManifest.cs +++ b/tools/PwshCorpusTool/CorpusManifest.cs @@ -554,5 +554,31 @@ private static string NestIex(string inner, int depth) "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."), }; }