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
8 changes: 8 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
<!-- Test stack -->
<XunitVersion>2.9.2</XunitVersion>
<XunitRunnerVersion>2.8.2</XunitRunnerVersion>
<!-- Sample stack -->
<AspNetCoreVersion>8.0.26</AspNetCoreVersion>
</PropertyGroup>
<!-- Test dependencies -->
<ItemGroup>
Expand All @@ -16,4 +18,10 @@
<ItemGroup>
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="10.0.201" />
</ItemGroup>
<!-- Sample dependencies -->
<ItemGroup>
<PackageVersion Include="System.CommandLine" Version="2.0.7" />
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly" Version="$(AspNetCoreVersion)" />
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="$(AspNetCoreVersion)" />
</ItemGroup>
</Project>
199 changes: 158 additions & 41 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,73 +1,165 @@
# ShellSyntaxTree

A focused .NET library that parses shell command strings into a structured AST.
Purpose-built for **security gate evaluators** — tools that inspect agent-emitted
shell commands and decide whether to allow, prompt for, or deny execution.
[![NuGet](https://img.shields.io/nuget/v/ShellSyntaxTree.svg)](https://www.nuget.org/packages/ShellSyntaxTree/)

ShellSyntaxTree is **not** a shell interpreter. It does not execute, expand,
or evaluate commands. It returns an AST (verb chain, args with path
classification, redirects, compound operators, `cd`-in-compound propagation,
`bash -c` recursion) that consumers walk to make policy decisions.
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.

The original consumer is [Netclaw](https://github.com/netclaw-dev/netclaw)'s
approval policy. Any tool that needs to reason about the shape of an
agent-emitted shell command — without running it — is welcome to consume it.
Hand-rolled, AOT-trim friendly, zero native dependencies. Multi-targets
`netstandard2.0` and `net8.0`.

## Status
```bash
dotnet add package ShellSyntaxTree --version 0.1.0-alpha
```

## What you get

For an input like `cd /repo && rm /etc/passwd`, ShellSyntaxTree produces:

```mermaid
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
```

**v0.1 — pre-alpha.** Public API surface and behavior are specified in
[`SPEC.md`](./SPEC.md). Implementation is in progress.
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.

## Things you can do with it

- **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 | bash` from 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_history` or
`auditd` records 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](https://github.com/netclaw-dev/netclaw)'s approval policy;
the library is built to be reusable beyond that.

## Quick start

## Why not `tree-sitter-bash`?
```csharp
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:

Native dependencies, AOT trim concerns, and IDE-grade fidelity we don't need.
We want unsupported constructs to mark `IsUnparseable = true` so consumers
route to safe-fail. See [`SPEC.md` Appendix B](./SPEC.md) for the full
trade-off analysis.
```
None cd
path: /repo
AndIf rm
↳ cwd: /repo
path: /etc/passwd
```

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

```csharp
namespace ShellSyntaxTree;

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

public sealed record ParsedCommand { /* Source, Clauses, IsUnparseable, ... */ }
public sealed record Clause { /* Operator, Verb, Args, Redirects, ... */ }
public sealed record VerbChain { /* Tokens */ }
public sealed record Arg { /* Raw, Resolved, Kind, IsPath, ... */ }
public sealed record Redirect { /* Direction, Target */ }
public sealed record ParsedCommand { /* Source, Clauses, IsUnparseable, */ }
public sealed record Clause { /* Operator, Verb, Args, Redirects, */ }
public sealed record VerbChain { /* Tokens, Joined */ }
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 }
```

PowerShell and Windows `cmd` parsers are deferred to later versions; the
`IShellParser` seam is already in place so consumers don't have to refactor
when they ship.
`IShellParser` seam is in place so consumers don't refactor when they
ship.

Full behavioral contract: [`SPEC.md`](./SPEC.md).

## Multi-targeting
## Samples

`netstandard2.0` for broad consumer compatibility, `net8.0` for modern
runtimes. Tests target `net10.0`.
Two runnable samples live under [`samples/`](./samples).

## Repository layout
### `ShellSyntaxTree.Cli.Sample` — terminal explainer + audit policy

```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"
```
src/ShellSyntaxTree/ # library (TBD)
tests/ShellSyntaxTree.Tests/ # xunit tests + corpus runner (TBD)
tests/ShellSyntaxTree.Tests/Corpus/ # JSON test corpus (acceptance contract)
SPEC.md # the implementation specification
PROJECT_CONTEXT.md # what this is, who it serves
TOOLING.md # available tooling and how to access it
AGENTS.md / CLAUDE.md # agent operating constitution
IMPLEMENTATION_PLAN.md # NOW / NEXT / LATER work tracker

`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`](./samples/ShellSyntaxTree.Cli.Sample/Commands/AuditPolicy.cs)
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.

```bash
dotnet run --project samples/ShellSyntaxTree.Web.Sample
# → http://localhost:5239
```

## Building
![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.

## Building from source

```bash
dotnet tool restore
Expand All @@ -76,9 +168,34 @@ dotnet test -c Release
dotnet pack -c Release -o ./bin/nuget
```

`global.json` pins the SDK version. Targeting requires .NET 10 SDK or later
(`.slnx` solution format).
`global.json` pins the SDK; you need .NET 10 SDK or later for the
`.slnx` solution format.

## Versioning

- **v0.1.0-alpha** — first publishable cut. Bash-only.
- **v0.1.x** — additive (more verb table entries, more corpus, bug
fixes).
- **v0.2.0** — first PowerShell parser.
- **v1.0.0** — when an external consumer beyond Netclaw ships against
it without finding API gaps.

## License

[Apache-2.0](./LICENSE). Copyright © 2026 Aaron Stannard.

---

**Repository layout** — for contributors and curious agents:

| Path | What |
|---|---|
| `src/ShellSyntaxTree/` | The library |
| `tests/ShellSyntaxTree.Tests/` | xUnit unit tests + corpus runner |
| `tests/ShellSyntaxTree.Tests/Corpus/bash/*.json` | 115 corpus entries — the acceptance contract |
| `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) |
| `PROJECT_CONTEXT.md`, `TOOLING.md`, `AGENTS.md` | Repo governance — for autonomous agents |
| `IMPLEMENTATION_PLAN.md` | NOW / NEXT / LATER work tracker |
4 changes: 4 additions & 0 deletions ShellSyntaxTree.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,8 @@
<Folder Name="/tests/">
<Project Path="tests/ShellSyntaxTree.Tests/ShellSyntaxTree.Tests.csproj" />
</Folder>
<Folder Name="/samples/">
<Project Path="samples/ShellSyntaxTree.Cli.Sample/ShellSyntaxTree.Cli.Sample.csproj" />
<Project Path="samples/ShellSyntaxTree.Web.Sample/ShellSyntaxTree.Web.Sample.csproj" />
</Folder>
</Solution>
Binary file added assets/sample-web-bash-c.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/sample-web-build-script.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/sample-web-custom.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/sample-web-dynamic-cwd.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/sample-web-empty.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/sample-web-source-tab.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/sample-web-subshell.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/sample-web-unparseable.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
87 changes: 87 additions & 0 deletions samples/ShellSyntaxTree.Cli.Sample/Commands/AuditCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
using System.Text;
using ShellSyntaxTree;

namespace ShellSyntaxTree.Cli.Sample.Commands;

internal static class AuditCommand
{
public static int Run(string command)
{
var parser = new BashParser();
var parsed = parser.Parse(command);

var anyDeny = false;
var anyWarn = false;

if (parsed.IsUnparseable)
{
var decision = AuditPolicy.Evaluate(new Clause(), parsed, null);
EmitLine(0, "(unparseable input)", decision);
return decision.Outcome == AuditOutcome.Deny ? 1 : 2;
}

for (var i = 0; i < parsed.Clauses.Count; i++)
{
var clause = parsed.Clauses[i];
var next = i + 1 < parsed.Clauses.Count ? parsed.Clauses[i + 1] : null;

var decision = AuditPolicy.Evaluate(clause, parsed, next);
EmitLine(i, SummarizeClause(clause), decision);

switch (decision.Outcome)
{
case AuditOutcome.Deny: anyDeny = true; break;
case AuditOutcome.Warn: anyWarn = true; break;
}
}

if (anyDeny)
{
return 1;
}

if (anyWarn)
{
return 2;
}

return 0;
}

private static void EmitLine(int index, string summary, AuditDecision decision)
{
var label = decision.Outcome switch
{
AuditOutcome.Ok => "OK ",
AuditOutcome.Warn => "WARN",
AuditOutcome.Deny => "DENY",
_ => "? ",
};

var sb = new StringBuilder();
sb.Append(label).Append(" | Clause ").Append(index).Append(": ").Append(summary);
if (!string.IsNullOrEmpty(decision.Reason))
{
sb.Append(" — ").Append(decision.Reason);
}

Console.WriteLine(sb.ToString());
}

private static string SummarizeClause(Clause clause)
{
var verb = clause.Verb.Tokens.Count == 0 ? "(no verb)" : clause.Verb.Joined;
var args = new StringBuilder();
foreach (var arg in clause.Args)
{
if (arg.IsCwdAttribution)
{
continue;
}

args.Append(' ').Append(arg.Raw);
}

return args.Length == 0 ? verb : verb + args;
}
}
Loading
Loading