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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
9 changes: 9 additions & 0 deletions .github/workflows/pr_validation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,15 @@ jobs:
shell: pwsh
run: ./scripts/Add-FileHeaders.ps1 -Verify

- name: "Verify pwsh is available (SPEC.POWERSHELL.md §13 oracle gate)"
shell: bash
run: |
if ! command -v pwsh >/dev/null 2>&1; then
echo "::error::pwsh is not on PATH — the PowerShell corpus oracle gate (PwshOracleTests) would silently skip."
exit 1
fi
pwsh --version

- name: "dotnet restore"
run: dotnet restore

Expand Down
6 changes: 3 additions & 3 deletions Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<VersionPrefix>0.1.5</VersionPrefix>
<VersionSuffix></VersionSuffix>
<VersionPrefix>0.2.0</VersionPrefix>
<VersionSuffix>alpha</VersionSuffix>
</PropertyGroup>
<PropertyGroup>
<!-- Target framework matrix -->
Expand All @@ -29,7 +29,7 @@
</ItemGroup>
<!-- NuGet package metadata -->
<PropertyGroup>
<PackageTags>bash;shell;parser;ast;security;agent;syntax-tree</PackageTags>
<PackageTags>bash;powershell;pwsh;shell;parser;ast;security;agent;syntax-tree</PackageTags>
<PackageProjectUrl>https://github.com/Aaronontheweb/ShellSyntaxTree</PackageProjectUrl>
<RepositoryUrl>https://github.com/Aaronontheweb/ShellSyntaxTree</RepositoryUrl>
<RepositoryType>git</RepositoryType>
Expand Down
358 changes: 79 additions & 279 deletions IMPLEMENTATION_PLAN.md

Large diffs are not rendered by default.

73 changes: 43 additions & 30 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,17 @@

[![NuGet](https://img.shields.io/nuget/v/ShellSyntaxTree.svg)](https://www.nuget.org/packages/ShellSyntaxTree/)

A focused .NET library that parses bash command strings into a structured
AST. Purpose-built for tools that need to **reason about shell commands
without running them** — approval gates for LLM-emitted commands, CI/CD
script auditors, sandbox policy generators, audit-log analytics.
A focused .NET library that parses **bash and PowerShell** command strings
into a structured AST. Purpose-built for tools that need to **reason about
shell commands without running them** — approval gates for LLM-emitted
commands, CI/CD script auditors, sandbox policy generators, audit-log
analytics.

Hand-rolled, AOT-trim friendly, zero native dependencies. Multi-targets
`netstandard2.0` and `net8.0`.

```bash
dotnet add package ShellSyntaxTree --version 0.1.0-alpha
dotnet add package ShellSyntaxTree --version 0.2.0-alpha
```

## What you get
Expand Down Expand Up @@ -94,18 +95,22 @@ AndIf rm
path: /etc/passwd
```

## Public API surface (locked for v0.1)
## Public API surface

```csharp
namespace ShellSyntaxTree;

public interface IShellParser { ParsedCommand Parse(string command); }
public sealed class BashParser : IShellParser { /* … */ }
public sealed record BashParserOptions { /* HomeDirectory, WorkingDirectory */ }
public sealed class PwshParser : IShellParser { /* … */ } // v0.2.0

public abstract record ShellParserOptions { /* HomeDirectory, WorkingDirectory */ }
public sealed record BashParserOptions : ShellParserOptions;
public sealed record PwshParserOptions : ShellParserOptions;

public sealed record ParsedCommand { /* Source, Clauses, IsUnparseable, … */ }
public sealed record Clause { /* Operator, Verb, Args, Redirects, */ }
public sealed record VerbChain { /* Tokens, Joined */ }
public sealed record Clause { /* Operator, Verb, Args, Redirects, IsSubshell, IsCommandStringWrapped */ }
public sealed record VerbChain { /* Tokens, Joined, CanonicalVerb, IsDynamic */ }
public sealed record Arg { /* Raw, Resolved, Kind, IsPath, IsCwdAttribution, IsFlag */ }
public sealed record Redirect { /* Direction, Target, IsDynamicSkip */ }

Expand All @@ -114,11 +119,12 @@ public enum RedirectDirection { In, Out, Append, ErrOut, ErrAppend }
public enum CompoundOperator { None, AndIf, OrIf, Sequence, Pipe }
```

PowerShell and Windows `cmd` parsers are deferred to later versions; the
`IShellParser` seam is in place so consumers don't refactor when they
ship.
Both parsers emit the **same** `ParsedCommand` AST — a consumer walks a
PowerShell parse exactly as it walks a bash one. A Windows `cmd` parser
remains deferred.

Full behavioral contract: [`SPEC.md`](./SPEC.md).
Behavioral contract: [`SPEC.md`](./SPEC.md) (bash + shared surface) and
[`SPEC.POWERSHELL.md`](./SPEC.POWERSHELL.md) (PowerShell).

## Samples

Expand All @@ -129,6 +135,9 @@ Two runnable samples live under [`samples/`](./samples).
```bash
dotnet run --project samples/ShellSyntaxTree.Cli.Sample -- explain "cd /repo && rm /etc/passwd"
dotnet run --project samples/ShellSyntaxTree.Cli.Sample -- audit "cd /repo && rm /etc/passwd"

# --shell pwsh routes the same explain / audit logic through PwshParser:
dotnet run --project samples/ShellSyntaxTree.Cli.Sample -- explain --shell pwsh "gci C:\logs | rm"
```

`explain` pretty-prints the AST with `[flag]` / `[path]` / `[cwd-attr]` /
Expand All @@ -141,11 +150,11 @@ for the policy code — ~50 lines.

### `ShellSyntaxTree.Web.Sample` — Blazor WebAssembly Mermaid visualizer

Paste a bash script, watch the parsed AST render as a 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` recursion.
Paste a bash or PowerShell script, watch the parsed AST render as a
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.

```bash
dotnet run --project samples/ShellSyntaxTree.Web.Sample
Expand All @@ -154,10 +163,11 @@ dotnet run --project samples/ShellSyntaxTree.Web.Sample

![Build script preset](./assets/sample-web-build-script.png)

The visualizer ships preset scripts demonstrating compound commands,
subshell isolation, `bash -c` recursion, dynamic-cwd attribution, and
unparseable inputs (control-flow, function definitions). Each preset
shows what the library produces in a single click.
A shell selector switches between the bash and PowerShell parsers; each
ships preset scripts demonstrating compound commands, subshell isolation,
command-string recursion, alias resolution, dynamic-cwd attribution, and
unparseable inputs. Each preset shows what the library produces in a
single click.

## Building from source

Expand All @@ -176,10 +186,12 @@ dotnet pack -c Release -o ./bin/nuget
Tags are bare SemVer version numbers — no `v` prefix. The release
workflow asserts this and fails fast on misformatted tags.

- **0.1.0-alpha** — first publishable cut. Bash-only.
- **0.1.x** — additive (more verb table entries, more corpus, bug
fixes).
- **0.2.0** — first PowerShell parser.
- **0.1.x** — bash parser. Additive after `0.1.0` (more verb table
entries, more corpus, bug fixes).
- **0.2.0** — first PowerShell parser (`PwshParser`). Adds the shared
`ShellParserOptions` base and the additive `VerbChain.CanonicalVerb` /
`VerbChain.IsDynamic` fields; renames `Clause.IsBashCWrapped` →
`IsCommandStringWrapped` (breaking — see [`RELEASE_NOTES.md`](./RELEASE_NOTES.md)).
- **1.0.0** — when an external consumer beyond Netclaw ships against
it without finding API gaps.

Expand All @@ -193,12 +205,13 @@ workflow asserts this and fails fast on misformatted tags.

| Path | What |
|---|---|
| `src/ShellSyntaxTree/` | The library |
| `src/ShellSyntaxTree/` | The library (bash + PowerShell parsers) |
| `tests/ShellSyntaxTree.Tests/` | xUnit unit tests + corpus runner |
| `tests/ShellSyntaxTree.Tests/Corpus/bash/*.json` | 115 corpus entries — the acceptance contract |
| `tests/ShellSyntaxTree.Tests/Corpus/<shell>/*.json` | Corpus entries — the acceptance contract (bash + powershell) |
| `samples/ShellSyntaxTree.Cli.Sample/` | Console explainer + audit policy |
| `samples/ShellSyntaxTree.Web.Sample/` | Blazor WASM Mermaid visualizer |
| `SPEC.md` | Locked v0.1 contract |
| `openspec/` | Change-proposal history (rationale for v0.1 design decisions) |
| `tools/PwshCorpusTool/` | PowerShell corpus authoring aid |
| `SPEC.md`, `SPEC.POWERSHELL.md` | The behavioral contract |
| `openspec/` | Change-proposal history (rationale for design decisions) |
| `PROJECT_CONTEXT.md`, `TOOLING.md`, `AGENTS.md` | Repo governance — for autonomous agents |
| `IMPLEMENTATION_PLAN.md` | NOW / NEXT / LATER work tracker |
84 changes: 84 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,87 @@
#### 0.2.0-alpha May 19th 2026 ####

First **PowerShell** parser. ShellSyntaxTree now ships two `IShellParser`
implementations — `BashParser` (unchanged) and the new `PwshParser` — both
emitting the same `ParsedCommand` AST a consumer already walks for bash.
Shipped as an **alpha** prerelease so Netclaw can validate the new parser
and the breaking `Clause` rename before promotion to a stable `0.2.0`.

**BREAKING: `Clause.IsBashCWrapped` renamed to `Clause.IsCommandStringWrapped`**

The v0.1 field `Clause.IsBashCWrapped` is renamed `Clause.IsCommandStringWrapped`.
The meaning is unchanged and now shell-neutral — *true when the clause is the
result of recursing into a command-string wrapper*: bash `bash -c "..."` /
`sh -c "..."`, or PowerShell `pwsh -Command "..."` / `pwsh -EncodedCommand ...`.

| Old (v0.1) | New (v0.2.0) |
|---|---|
| `Clause.IsBashCWrapped` | `Clause.IsCommandStringWrapped` |

A breaking AST change on a `0.x` minor is permitted by `SPEC.md` Appendix A
when `RELEASE_NOTES.md` carries the old→new mapping (above) and Netclaw is
updated in lockstep. Consumers: rename every `IsBashCWrapped` reference;
there is no behavior change beyond the identifier.

**BREAKING (source-compatible): `BashParserOptions` reparented**

`BashParserOptions` is now a sealed record deriving from the new abstract
`ShellParserOptions` base; `HomeDirectory` / `WorkingDirectory` move to the
base. The object-initializer shape is unchanged —
`new BashParserOptions { HomeDirectory = ..., WorkingDirectory = ... }`
still compiles. Only code that named `BashParserOptions` as a *base type*
or reflected over its declared members is affected.

**New public surface**

- `PwshParser : IShellParser` — the PowerShell parser. `Parse` throws
`ArgumentNullException` on null and never throws on a well-formed string,
exactly like `BashParser`.
- `PwshParserOptions` — configuration record for `PwshParser` (empty in
v0.2.0; resolver knobs live on `ShellParserOptions`).
- `ShellParserOptions` — the shared, abstract resolver-configuration base.
- `VerbChain.CanonicalVerb` (additive) — the alias-resolved canonical verb,
non-null only when an alias was rewritten (`ls` → `Get-ChildItem`). Null
for every bash clause. Consumers gate on `CanonicalVerb ?? Tokens[0]`.
- `VerbChain.IsDynamic` (additive) — true when the command name is a
dynamic token the parser cannot statically identify (`& $exe`,
`& { ... }`). Always false for bash clauses; a consumer MUST route a
dynamic clause to safe-fail.

**PowerShell parser capabilities (SPEC.POWERSHELL.md)**

- Parses PowerShell command pipelines into the shared `ParsedCommand` AST —
per-clause verbs, args, parameters, redirects, and the `&&` / `||` / `;`
/ `|` / newline compound operators.
- Recognizes cmdlets (`Verb-Noun`), native commands, and the complete
built-in alias set; resolves aliases to their canonical cmdlet while
preserving the verbatim typed token.
- The §6.5 parameter-binding model — switch vs. value-binding decisions
from static tables, colon-form `-Name:value`, prefix matching.
- Per-cmdlet / per-parameter path-arg extraction (`-Path`, `-LiteralPath`,
`-Destination`, positional rules).
- `Set-Location <dir>; cmd` cwd propagation, including through `( ... )`
grouping (PowerShell `( )` is not a subshell).
- Recursion into `pwsh -Command "<inner>"`, `pwsh -c`, and
`pwsh -EncodedCommand <base64>` (base64 / UTF-16LE decode, BOM strip),
depth-5 capped; inner clauses surface with `IsCommandStringWrapped=true`.
- Marks dynamic-content tokens (`$var`, subexpressions, script blocks,
splatting, comma-arrays) `DynamicSkip`; control flow, definitions, and
other script-level constructs safe-fail to `IsUnparseable=true`.
- A 64 KiB input cap guards the per-shell-call hot path.

**Corpus & validation**

- 211 hand-authored PowerShell corpus entries under
`Corpus/powershell/`, exceeding every SPEC.POWERSHELL.md §13 category
minimum. The corpus runner and PII audit are directory-routed by shell.
- A real-`pwsh` validation gate (`PwshOracleTests`) feeds every PowerShell
corpus input to `[Parser]::ParseInput` and enforces the §13 oracle
matrix; a `PwshAliases`-vs-live-`Get-Alias` completeness `[Fact]`
confirms the alias table has no gaps.
- `tools/PwshCorpusTool` — the corpus authoring aid (see `TOOLING.md`).

---

#### 0.1.5 May 16th 2026 ####

Stable promotion of 0.1.5-beta. No code changes from the beta; this release
Expand Down
Loading
Loading