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.
dotnet add package ShellSyntaxTree --version 0.2.0The 0.2.0 release adds PowerShell support. The latest stable
0.2.x package supports bash and PowerShell together. The public surface
documented below tracks the dev branch.
For an input like cd /repo && rm /etc/passwd, ShellSyntaxTree produces:
flowchart TD
classDef bad fill:#fee,stroke:#b00,stroke-width:2px
A[cd /repo<br/>π /repo] -- "&&" --> B[rm<br/>π /etc/passwd<br/>cwd: /repo]
class B bad
A two-clause AST where the second clause's Args includes a synthetic
/repo attribution arg (so consumers can see "this rm is implicitly
operating in /repo") and /etc/passwd is resolved and marked
IsPath = true. Hard-deny rules over /etc/* fire immediately; no
substring matching, no shelling out, no false positives.
- Approval gates for AI agents β given a command emitted by an LLM, decide ALLOW / PROMPT / DENY before invoking the shell.
- CI/CD pipeline audits β scan shell steps in GitHub Actions /
Jenkinsfile / Azure Pipelines for writes outside the workspace,
curl | bashfrom non-allowlisted hosts, hardcoded credential echoes. - Sandbox / container policy β derive the minimum-viable volume mount set or AppArmor profile from a build script.
- Pre-commit linters β flag dangerous patterns (
rm -rf /,chmod 777 /etc/*) in shell scripts at commit time. - Shell history / audit-log analytics β ingest
~/.bash_historyorauditdrecords into structured form for SIEM-style insights. - Documentation / explainers β convert complex one-liners into readable structure for tutorials and runbooks.
The original consumer is Netclaw's approval policy; the library is built to be reusable beyond that.
using ShellSyntaxTree;
var parser = new BashParser();
var parsed = parser.Parse("cd /repo && rm /etc/passwd");
if (parsed.IsUnparseable)
{
// Safe-fail: prompt the user, deny the command, etc.
Console.WriteLine($"can't model: {parsed.UnparseableReason}");
return;
}
foreach (var clause in parsed.Clauses)
{
Console.WriteLine($"{clause.Operator} {clause.Verb.Joined}");
foreach (var arg in clause.Args.Where(a => a.IsPath))
{
var marker = arg.IsCwdAttribution ? "β³ cwd" : " path";
Console.WriteLine($" {marker}: {arg.Resolved}");
}
foreach (var redirect in clause.Redirects.Where(r => !r.IsDynamicSkip))
{
Console.WriteLine($" {redirect.Direction}: {redirect.Target}");
}
}Run that against the example input and you get:
None cd
path: /repo
AndIf rm
β³ cwd: /repo
path: /etc/passwd
The consumer guide develops the quick start into a production-oriented algorithm: parser selection, safe-fail handling, command identity, paths and cwd attribution, redirects, compounds and pipelines, and PowerShell-specific alias and dynamic-command behavior. It also links to immutable examples from Netclaw's live approval-gate integration.
namespace ShellSyntaxTree;
public interface IShellParser { ParsedCommand Parse(string command); }
public sealed class BashParser : IShellParser { /* β¦ */ }
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, Elements, IsSubshell, IsCommandStringWrapped */ }
public sealed record ClauseElement { /* Raw, Value, Role, source span, verb-relative position, path facts */ }
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 */ }
public enum ArgKind { Literal, EnvVar, Glob, Tilde, DynamicSkip }
public enum ClauseElementRole { Verb, Argument, Redirect }
public enum RedirectDirection { In, Out, Append, ErrOut, ErrAppend }
public enum CompoundOperator { None, AndIf, OrIf, Sequence, Pipe }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.
Behavioral contract: SPEC.md (bash + shared surface) and
SPEC.POWERSHELL.md (PowerShell).
Two runnable samples live under samples/.
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] /
[dyn-skip] / [glob] markers per arg. audit runs a small built-in
policy ("deny writes in /etc, /usr, /bin, /sbin, /lib",
"warn on curl | bash", "warn on dynamic args in path slots") and
exits 0 / 1 / 2 by severity. See
samples/ShellSyntaxTree.Cli.Sample/Commands/AuditPolicy.cs
for the illustrative policy code.
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.
Static Invoke-Expression / iex payloads surface the same way, while
computed payloads safe-fail.
dotnet run --project samples/ShellSyntaxTree.Web.Sample
# β http://localhost:5239A shell selector switches between the bash and PowerShell parsers. The presets demonstrate compound commands, Bash subshell isolation, PowerShell grouping and alias resolution, command-string recursion, dynamic-cwd attribution, and unparseable inputs. Each preset shows what the library produces in a single click.
dotnet tool restore
dotnet build -c Release
dotnet test -c Release
dotnet pack -c Release -o ./bin/nugetglobal.json pins the SDK; you need .NET 10 SDK or later for the
.slnx solution format.
Tags are bare SemVer version numbers β no v prefix. The release
workflow asserts this and fails fast on misformatted tags.
- 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 sharedShellParserOptionsbase and the additiveVerbChain.CanonicalVerb/VerbChain.IsDynamicfields; renamesClause.IsBashCWrappedβIsCommandStringWrapped(breaking β seeRELEASE_NOTES.md). - 1.0.0 β when an external consumer beyond Netclaw ships against it without finding API gaps.
Apache-2.0. Copyright Β© 2026 Aaron Stannard.
Repository layout β for contributors and curious agents:
| Path | What |
|---|---|
src/ShellSyntaxTree/ |
The library (bash + PowerShell parsers) |
tests/ShellSyntaxTree.Tests/ |
xUnit unit tests + corpus runner |
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 |
docs/CONSUMER_GUIDE.md |
Production-oriented consumer algorithm + Netclaw case study |
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 |
