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.0-alphaFor 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
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, 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 */ }
public enum ArgKind { Literal, EnvVar, Glob, Tilde, DynamicSkip }
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 policy code β ~50 lines.
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.
dotnet run --project samples/ShellSyntaxTree.Web.Sample
# β http://localhost:5239A 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.
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 |
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 |
