Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions IMPLEMENTATION_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,8 @@ Mermaid flowchart in your browser. Everything runs client-side — pasted
scripts never leave your machine. Useful for "what does this script
actually do?" moments and for understanding how the library models
constructs like subshells and `bash -c` / `pwsh -Command` recursion.
Static `Invoke-Expression` / `iex` payloads surface the same way, while
computed payloads safe-fail.

```bash
dotnet run --project samples/ShellSyntaxTree.Web.Sample
Expand Down
13 changes: 13 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,16 @@
#### Unreleased ####

## Added

- **Surfaced static `Invoke-Expression` payloads for security gates (#63)**
`PwshParser` now recurses into provably static `Invoke-Expression` / `iex`
strings, preserves current-scope `Set-Location` attribution, and applies the
existing command-string size and depth limits. Variables, interpolation,
concatenation, subexpressions, and pipeline input now route through
`DynamicSkip` or `IsUnparseable` instead of producing a clean persistent
approval shape. PowerShell backtick and Unicode escapes are decoded before
recursion, and exact colon-form `-Command:` binding is supported.

#### 0.2.0-beta.1 2026-07-22 ####

## Fixed
Expand Down
113 changes: 87 additions & 26 deletions SPEC.POWERSHELL.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,10 @@ syntax (§5) are all PowerShell 7 semantics. The `pwsh` validation oracle
`-LiteralPath`, `-Destination`, positional rules).
4. Honor `Set-Location <dir>; cmd` propagation — subsequent clauses see
`<dir>` as cwd, mirroring bash `cd` (`SPEC.md` §9).
5. Recurse into `pwsh -Command "<inner>"`, `pwsh -c`, and
`pwsh -EncodedCommand <base64>` so inner command clauses surface to the
consumer.
5. Recurse into `pwsh -Command "<inner>"`, `pwsh -c`,
`pwsh -EncodedCommand <base64>`, 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
Expand Down Expand Up @@ -180,7 +181,8 @@ public bool IsDynamic { get; init; }

`IsDynamic` exists because PowerShell's call operator (`&`) makes invoking a
dynamically-named command a first-class, common idiom. A clause whose verb is
`$exe` is otherwise indistinguishable from one whose verb is a literal — and
`$exe` or an interpolated name such as `"tool-$name"` is otherwise
indistinguishable from one whose verb is a literal — and
"we do not know what is being executed" is the most security-relevant state
the AST can carry, so it gets a field rather than being silently flattened
into `Tokens`. The clause's args and redirects still parse normally so a
Expand All @@ -192,7 +194,7 @@ The v0.1 field `IsBashCWrapped` is renamed `IsCommandStringWrapped`. The
meaning is unchanged and now shell-neutral: *true when this clause is the
result of recursing into a command-string wrapper* — bash `bash -c "..."` /
`sh -c "..."`, or PowerShell `pwsh -Command "..."` / `pwsh -c "..."` /
`pwsh -EncodedCommand ...` (§10).
`pwsh -EncodedCommand ...` / static `Invoke-Expression '...'` (§10).

---

Expand Down Expand Up @@ -319,19 +321,32 @@ The `PwshLexer` produces tokens consumed by `PwshCommandParser`. Token kinds
`$var` / `${name}` / `$env:X` / `$( ... )` interpolation — but the parser
**does not expand**; `$var` stays literal in the token value and the
resolver (§8) classifies it. A `$( ... )` inside a double-quoted string
does not split the token.
does not split the token. Backtick character escapes are decoded into the
same logical value PowerShell passes to a command, including
`` `u{hex}`` Unicode scalar escapes.
- **Here-strings** — `@"` + newline ... newline + `"@` (expandable) and
`@'` + newline ... newline + `'@` (literal). The closing delimiter must
start a line. Lexes to one `QuotedString` token with `IsHereString=true`.
Expandable here-strings decode backtick character escapes; literal
here-strings preserve their body bytes.
- Unbalanced quotes or here-strings → `IsUnparseable` with a reason.

### Escape handling

- The backtick `` ` `` is PowerShell's escape character — **not** a
command-substitution delimiter. There is no backtick command substitution
in PowerShell. Outside quotes, `` `X `` takes `X` literally into the
current Word. Inside double quotes, `` `n ``, `` `t ``, `` `" ``, `` `$ ``,
and an escaped backtick are recognized.
in PowerShell. Outside quotes, a backtick escape contributes its decoded
character to the current Word. Inside expandable strings, `` `n ``,
`` `t ``, `` `" ``, `` `$ ``, an escaped backtick, and `` `u{hex}`` are
recognized; `` `e`` decodes to ESC. Unicode escapes accept one to six hex
digits up to `0x10FFFF`, including UTF-16 surrogate code units as
PowerShell does. A malformed Unicode escape emits an unparseable sentinel.
Decoding occurs before static command-string recursion so an
escaped newline cannot hide an additional command. Decoded PowerShell
whitespace, including vertical tab, form feed, and Unicode separator
characters, becomes a token boundary; only CR/LF separate statements. A
decoded NUL marks the command unparseable rather than being merged into a
verb or argument.
- Backtick + newline is a line continuation.

### Operator boundaries
Expand Down Expand Up @@ -804,7 +819,7 @@ pipeline elements within a statement, not separate statements.

---

## 10. Subexpression & `pwsh -Command` Recursion
## 10. Subexpression & Command-String Recursion

### Opaque regions

Expand Down Expand Up @@ -879,11 +894,53 @@ failure.

### `Invoke-Expression`

`Invoke-Expression` / `iex` is **not** recursed into — evaluating its string
argument requires PowerShell expression semantics, which is out of scope. It
parses as an ordinary clause; its argument is a normal `Arg` (`DynamicSkip`
when it is `$var` or `$( ... )`). A consumer that wants to gate dynamic code
execution can hard-deny the verb `Invoke-Expression` / `iex` itself.
`Invoke-Expression` and its canonical `iex` alias recurse only when binding
produces exactly one scalar string token whose value is statically knowable.
Static call-operator spellings such as `& 'iex' ...` and module-qualified
`Microsoft.PowerShell.Utility\Invoke-Expression` receive the same handling.
Accepted payloads are a single-quoted string, a literal here-string, a bare
non-dynamic word, or a double-quoted string / expandable here-string whose
lexer token records no unescaped variable or subexpression interpolation.
An optional exact `-Command` parameter may bind that one token. The normal
colon forms are accepted: `-Command:Get-Date` carries an inline bare value,
while `-Command:'Get-Date'` and `-Command:"Get-Date"` bind the following
quoted token. Inline values receive the same backtick decoding as ordinary
words. A `#` immediately after an empty colon starts a comment and therefore
leaves the required payload missing. Dynamic inline values remain opaque.

For a static payload, the parser consumes the outer expression clause and
surfaces the inner clauses inline with `IsCommandStringWrapped = true`. The
first inner clause takes the operator that preceded the outer expression.
The parse increments the same depth counter used by `pwsh -Command` and
`-EncodedCommand`, and the payload passes through the same 64 KiB input cap.

`Invoke-Expression` executes in the caller's scope rather than a fresh child
process. Its inner parse therefore shares the current `Set-Location` context:
relative paths inherit the caller's effective location, and a static inner
`Set-Location` updates attribution for clauses following the expression in
the outer command. Child `pwsh` recursion remains isolated.

The parser never evaluates variables, interpolation, concatenation,
subexpressions, script blocks, arrays, or other computed expressions. When a
direct computed payload has a source expression, the outer expression clause
remains and the entire payload source slice becomes one
`Arg { Kind=DynamicSkip, IsPath=false, Resolved=null }`. Pipeline input,
missing payloads, and ambiguous parameter binding set
`ParsedCommand.IsUnparseable = true`; an incoming pipeline is dynamic even
when an explicit literal argument also appears. These rules prevent a clean,
persistently approvable `Invoke-Expression` clause from hiding runtime code.
Because computed code can call `Set-Location` in the current scope, a direct
dynamic payload also makes location attribution dynamic for every following
relative path.

The dot-source invocation operator and unsupported module-qualified cmdlets
are unparseable rather than being exposed under a misleading raw verb. The
one supported module-qualified wrapper remains
`Microsoft.PowerShell.Utility\Invoke-Expression`. A quoted string is a command
identity only when preceded by the call operator `&`; otherwise it is an
unsupported expression. Any dynamic command identity invalidates following
location attribution because it can resolve to current-scope code that calls
`Set-Location`.

---

Expand Down Expand Up @@ -914,10 +971,13 @@ defined in **`SPEC.md` §11** and is unchanged.
7. **Assignment statement** — a statement that begins `$var = ...`.
8. **Bare type-literal / .NET method call** — a statement that is just
`[type]::Member(...)`, which has no verb.
9. **`pwsh` recursion failure** — `pwsh -Command` / `-EncodedCommand`
recursion depth exceeds 5, an `-EncodedCommand` payload fails to decode,
or an inner parse itself yields `IsUnparseable` (§10).
10. **Oversized input** — the command string, or a decoded `-EncodedCommand`
9. **PowerShell command-string recursion failure** — the shared
`Invoke-Expression` / `pwsh -Command` / `-EncodedCommand` recursion depth
exceeds 5, an `-EncodedCommand` payload fails to decode, an expression
payload comes from a pipeline or cannot be bound safely, or an inner parse
itself yields `IsUnparseable` (§10).
10. **Oversized input** — the command string, a static `Invoke-Expression`
payload, or a decoded `-EncodedCommand`
payload, exceeds the parser's input cap. The cap guards the
per-shell-call hot path against a pathological or malicious input (a
multi-megabyte base64 blob would otherwise decode and recurse up to five
Expand All @@ -934,8 +994,8 @@ defined in **`SPEC.md` §11** and is unchanged.
`UnparseableSentinel` tokens; (3) a control-flow / definition / block keyword
at statement position; (4) a trailing `&` background job; (5) an assignment
or bare type-literal statement; (6) grouping `( )` balance errors or an
unexpected operator; (7) the `pwsh` recursion cap, an inner-parse
`IsUnparseable`, or an `-EncodedCommand` decode failure.
unexpected operator; (7) a command-string binding failure or recursion cap,
an inner-parse `IsUnparseable`, or an `-EncodedCommand` decode failure.

Consumers route an unparseable command to safe-fail exactly as for bash
(`SPEC.md` §11, Appendix A).
Expand Down Expand Up @@ -1059,9 +1119,9 @@ The shared corpus DTO gains two optional fields:
parser deliberately does not model — real `pwsh` must accept it). Defaults
to `SyntaxError`. `OutOfScope` also covers an input that is valid
PowerShell but that the parser declines for a non-grammar reason — an
`-EncodedCommand` decode failure, an over-cap input (§11), or a
recursion-depth overflow — because real `pwsh` parses the *outer*
invocation without error.
`-EncodedCommand` decode failure, dynamic pipeline-fed
`Invoke-Expression`, an over-cap input (§11), or a recursion-depth overflow
— because real `pwsh` parses the *outer* invocation without error.

### Coverage targets for v0.2.0

Expand All @@ -1076,7 +1136,7 @@ The shared corpus DTO gains two optional fields:
| Quote handling (single, double, here-string, backtick escape) | 10 |
| Parameter binding — named, positional, switch vs. value-binding (§6.5), colon-form, splat | 25 |
| Redirect (including streams 1–6 / `*` and `2>&1`) | 10 |
| `pwsh -Command` / `-EncodedCommand` recursion — incl. bare/script-block `-Command` forms and adversarial `-EncodedCommand` payloads (bad base64, BOM, decodes-to-control-flow, nested) | 15 |
| Command-string recursion — `pwsh -Command`, `-EncodedCommand`, and static/dynamic `Invoke-Expression` payloads, including nesting and location scope | 25 |
| Dynamic skip (`$var`, `$( )`, glob, script block, dynamic verb `& $exe`) | 10 |
| Per-verb / per-parameter path rules | 10 |
| Unparseable (control flow, definitions, `param()`, blocks, assignment, type-literal, trailing `&`, recursion overflow, over-cap input) | 20 |
Expand Down Expand Up @@ -1181,7 +1241,8 @@ testable step; most are a single PR.
6. **`PwshResolver`** — §8.
7. **Per-verb / per-parameter path rules** — §7.
8. **`Set-Location`-in-compound propagation** — §9.
9. **`pwsh -Command` / `-EncodedCommand` recursion** — §10.
9. **PowerShell command-string recursion** — `pwsh -Command`,
`-EncodedCommand`, and `Invoke-Expression` (§10).
10. **Anomaly safe-fail** — §11.
11. **Multi-shell refactor** of the corpus runner and PII audit — §13 — so
both enumerate every `Corpus/<shell>/` directory. This MUST precede
Expand Down
6 changes: 4 additions & 2 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,8 @@ public sealed record Clause
/// <summary>
/// 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.
/// </summary>
Expand Down Expand Up @@ -256,7 +257,8 @@ public sealed record VerbChain

/// <summary>
/// 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.
/// </summary>
public bool IsDynamic { get; init; }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-04
Loading