ShellSyntaxTree turns a shell command into facts that another system can use without executing the command. It is designed for approval gates, CI/CD auditors, sandbox planners, audit-log processors, and other tools that need to reason about commands before or after execution.
The library is deliberately not a policy engine. It reports clauses, candidate verb chains, arguments, paths, redirects, inherited working directories, and uncertainty. A consumer decides what those facts mean for its own domain.
ShellSyntaxTree owns shell syntax:
- splitting compounds and pipelines into clauses;
- recognizing Bash commands, PowerShell cmdlets, aliases, and native commands;
- resolving path-shaped arguments against the caller-supplied working directory;
- propagating
cd/Set-Locationworking-directory context; - surfacing redirects and command-string wrappers;
- marking dynamic or unsupported input so a security consumer can fail safely.
The consumer owns policy:
- which verbs or cmdlets are allowed;
- how broad an approval pattern should be;
- which filesystem zones are trusted;
- whether pipelines are displayed as one approval unit or several;
- executable-specific meaning, such as the difference between a Git global
option and a
git commitoption; - the final
ALLOW,PROMPT, orDENYdecision.
This separation is important. ShellSyntaxTree cannot safely embed the grammar of every executable. It should preserve the source facts a command-aware consumer needs, while remaining conservative when those facts are incomplete.
flowchart TD
A["Command text + selected shell + working directory"] --> B
B{"Caller selects the matching parser"}
subgraph SST["ShellSyntaxTree"]
C["BashParser"]
D["PwshParser"]
C --> E["Parse syntax, classify tokens, and resolve static context"]
D --> E
E --> F["ParsedCommand: ordered clauses and elements, semantic projections, cwd, and uncertainty"]
end
subgraph APP["Consumer-owned policy"]
G{"IsUnparseable or policy-relevant input dynamic?"}
G -->|Yes| H["Safe-fail: prompt or deny"]
G -->|No| I["Walk every clause in source order"]
I --> J["Choose command identity and evaluate paths, cwd, and redirects"]
J --> K{"ALLOW / PROMPT / DENY"}
end
B -->|Bash| C
B -->|PowerShell| D
F --> G
The diagram is a responsibility flow, not an execution flow: parsing never
runs the command, and every decision after ParsedCommand belongs to the
consumer.
The caller should know which shell will execute the command and select that parser explicitly. Supplying the real working directory is equally important: relative paths are resolved against it.
using ShellSyntaxTree;
static IShellParser CreateParser(string shell, string workingDirectory) =>
shell switch
{
"bash" => new BashParser(new BashParserOptions
{
WorkingDirectory = workingDirectory,
}),
"pwsh" => new PwshParser(new PwshParserOptions
{
WorkingDirectory = workingDirectory,
}),
_ => throw new ArgumentOutOfRangeException(nameof(shell)),
};Do not guess the shell from the command text. rm, cd, quoting, redirects,
and grouping can mean different things in Bash and PowerShell.
Once parsed, a security-oriented consumer normally follows this sequence:
- Reject or prompt on an unparseable result.
- Walk every clause; do not authorize only the first stage of a compound or pipeline.
- Determine a conservative command identity.
- Evaluate explicit path arguments, inherited cwd attribution, and redirect targets.
- Elevate dynamic or unresolved content when it affects the policy decision.
- Apply product-specific rules and produce a decision.
The decision types and Evaluate* helpers below are application-owned
placeholders. ShellSyntaxTree supplies the parsed facts, not those policy APIs.
var parser = CreateParser(shell, workingDirectory);
var parsed = parser.Parse(command);
if (parsed.IsUnparseable)
{
// Partial clauses are diagnostic evidence, not authorization evidence.
return GateDecision.Prompt(parsed.UnparseableReason ?? "unsupported command shape");
}
foreach (var clause in parsed.Clauses)
{
if (clause.Verb.IsDynamic)
{
return GateDecision.Prompt("command identity is dynamic");
}
var gateKey = GetGateKey(clause.Verb);
if (gateKey is null)
{
return GateDecision.Prompt("clause has no statically known command");
}
var decision = EvaluateClause(gateKey, clause);
if (decision.Outcome != GateOutcome.Allow)
{
return decision;
}
}
return GateDecision.Allow();The example returns on the first non-allow result for brevity. A real UI may collect every clause decision so the operator can see the complete command.
For PowerShell aliases, prefer the canonical cmdlet identity while retaining the token the user typed for display:
static string? GetGateKey(VerbChain verb)
{
if (verb.IsDynamic || verb.Tokens.Count == 0)
{
return null;
}
return verb.CanonicalVerb ?? verb.Tokens[0];
}For example, parsing gci C:\logs preserves gci in Tokens and reports
Get-ChildItem in CanonicalVerb. A policy can gate on Get-ChildItem; an
audit UI can still show gci.
VerbChain is a best-effort syntactic hint, not a complete executable grammar.
The greedy native-command walk can include bare lowercase values because a
generic parser cannot know whether origin is a Git remote or a subcommand.
Unknown commands should therefore retain the complete authored shape through a
strict pattern, producing narrower approvals and recoverable re-prompts. A
consumer may normalize or shorten that shape only when it owns command-specific
knowledge that justifies doing so.
Clause.Elements supports two security-conscious consumer strategies. The
choice belongs to the approval product, not the parser.
Strict matching evaluates the significant authored stream in order. A
pattern may contain explicit operand slots, but unexpected or intervening
elements prevent a match. For example, a strict git commit pattern does not
match git -C /repo commit, because -C /repo appears between the executable
and subcommand. This mode is easy to audit and fail-closed, but syntactic
variations can produce more prompts.
General matching uses an executable-aware interpreter. The interpreter
consumes the complete element stream according to that executable's option
grammar and returns a normalized approval identity plus the policy-relevant
operands and scopes. A Git interpreter can normalize git -C /repo commit to
git commit while retaining /repo as its effective-directory constraint.
This preserves reusable approvals without treating the option as irrelevant.
General matching does not mean filtering to Role=Verb or trusting
PrecedingVerbElementCount as a semantic boundary. Both fields describe the
generic parser's projection. If the executable-aware interpreter encounters an
unknown option, missing operand, dynamic value, or otherwise incomplete shape,
it should fall back to strict matching or prompt rather than broaden the
approval.
Netclaw is expected to use general matching for supported high-frequency commands so ordinary option placement does not create approval fatigue. Strict matching remains the safe fallback for commands whose grammar Netclaw does not yet understand.
An Arg carries several independent facts:
Rawis the user-facing token;IsFlagidentifies option-shaped tokens;Kinddescribes literal, environment-variable, glob, tilde, or dynamic content;IsPathsays the parser classified the argument position as a path;Resolvedcarries a normalized path when static resolution was possible;IsCwdAttributionmarks derived working-directory context rather than a token written in that clause.
These facts should not be collapsed into one boolean decision. A typical zone policy might handle them as follows:
foreach (var arg in clause.Args)
{
if (arg.IsCwdAttribution)
{
EvaluateInheritedDirectory(arg.Resolved, arg.Kind);
continue;
}
if (arg.Kind == ArgKind.DynamicSkip)
{
EvaluateUnknownArgument(arg.Raw);
continue;
}
if (!arg.IsPath)
{
continue;
}
if (arg.Kind == ArgKind.Glob)
{
EvaluateGlobCoveringDirectory(arg.Raw);
continue;
}
EvaluatePath(arg.Resolved ?? arg.Raw);
}The policy decides whether an unknown argument matters. echo $message may be
acceptable to one product, while Remove-Item $target should normally prompt.
Never treat DynamicSkip.Raw as a statically resolved path.
Command-valued native options use the same signal. GNU tar's -F,
--info-script, and --new-volume-script operands execute code, so the parser
reports their values as DynamicSkip rather than misleading path facts.
For cd /repo && cat file.txt, the cat clause receives a synthetic
IsCwdAttribution argument for /repo, and file.txt resolves against that
directory. PowerShell provides the same contract for Set-Location and its
aliases.
The attributed argument is derived context:
- use it when evaluating where a clause operates;
- do not render it as text the user wrote in that clause;
- treat a dynamic cwd attribution as unknown context and prompt rather than falling back to the process cwd.
Bash subshells isolate cwd changes. PowerShell parenthesized pipelines do not:
(Set-Location C:\repo); Get-ChildItem changes runspace location, so the later
clause inherits that attribution.
Redirect targets are operands too. A command that appears path-free can still write outside an allowed zone:
echo safe > /etc/profile.d/example.sh
Walk Clause.Redirects independently of Args:
foreach (var redirect in clause.Redirects)
{
if (redirect.IsDynamicSkip)
{
EvaluateUnknownRedirect(redirect.Target);
continue;
}
EvaluatePath(redirect.Target);
}PowerShell streams 3-6 and *> currently map lossily onto the shared redirect
enum. The target remains available for path policy, but consumers must not use
RedirectDirection to recover the exact original PowerShell stream.
ParsedCommand.Clauses is ordered. Each clause carries the operator that
preceded it:
AndIf,OrIf, andSequencenormally introduce a new statement;Pipeconnects pipeline stages;Nonemarks the first clause.
A UI may group a pipeline as one approval prompt, but authorization should
still inspect every stage. download | sh is unsafe even if download alone
is allowed.
ShellSyntaxTree also looks through supported command-string wrappers. Clauses
surfaced from bash -c, pwsh -Command, and pwsh -EncodedCommand carry
IsCommandStringWrapped = true. The outer wrapper is not the action a
verb-based policy should authorize; the surfaced inner clauses are.
Redirects authored on the outer PowerShell wrapper remain attached to the last
surfaced clause, so redirect policy still sees paths such as
pwsh -Command "git status" > audit.log.
PowerShell script blocks, subexpressions, splats, and --% regions are opaque
and surface as DynamicSkip. A dynamically invoked command such as & $exe
sets VerbChain.IsDynamic = true; no verb-pattern grant should match it.
For a security gate, these conditions should prevent a durable automatic grant:
ParsedCommand.IsUnparseableis true;- the non-empty input produces no clauses;
- a clause has
Verb.IsDynamicor no statically known command identity; - a dynamic argument or redirect affects a policy-sensitive position;
- a future package version introduces an enum or AST shape the consumer has not mapped.
The recoverable outcome is normally a user prompt with a one-time option, or a deny. A false-negative approval match causes another prompt; a false-positive match can silently execute something the operator did not authorize.
Input:
cd /repo && rm -rf build
The consumer can derive:
- two clauses joined by
AndIf; - action
rmwith flags-rf; - explicit path
build, resolved beneath/repo; - inherited cwd
/repoon thermclause.
The product can allow deletion under a disposable build directory, prompt for an unfamiliar workspace, and deny protected system zones.
Input:
dotnet test > /tmp/test.log && curl https://example.invalid/install | bash
The auditor can inspect the redirect path, split the second statement from the first, recognize the pipeline, and warn that downloaded content is piped into a shell.
Input:
Set-Location C:\src; Copy-Item .\out\app.dll C:\deploy\app.dll
The consumer can collect the attributed cwd and both path operands to propose read/write mounts. It should still apply its own cmdlet policy and access-mode rules; ShellSyntaxTree reports paths, not filesystem permissions.
Input:
git -C /repo commit
git commit -C HEAD~1
git -C /repo commit -C HEAD~1
git --no-pager commit -C HEAD~1
These commands demonstrate why source provenance matters. Git assigns different
meaning to -C based on whether it appears before or after commit.
Issue #62
introduced Clause.Elements so a Git-aware consumer can apply that rule
without re-tokenizing ParsedCommand.Source. The consumer must interpret the
complete authored stream using Git's grammar; Role and
PrecedingVerbElementCount mirror ShellSyntaxTree's greedy projection and are
not Git-semantic boundaries:
var authored = clause.Elements
.Where(element => element.Role != ClauseElementRole.Redirect)
.ToArray();
// Application-owned code: walk every authored element, apply Git's global
// option arity, locate the semantic subcommand, and bind every option operand.
if (!GitCommandGrammar.TryInterpret(authored, out var command))
{
return ApprovalDecision.FailClosed;
}
foreach (var occurrence in command.Options.Where(option => option.Name is "-c" or "-C"))
{
if (occurrence.Operand is null
|| occurrence.Operand.Kind == ArgKind.DynamicSkip)
{
return ApprovalDecision.FailClosed;
}
if (occurrence.Scope == GitOptionScope.Global)
EvaluateGitGlobalOption(occurrence.Name, occurrence.Operand);
else if (command.Subcommand == "commit")
EvaluateGitCommitOption(occurrence.Name, occurrence.Operand);
}For git -C /repo commit, the -C and /repo elements report one preceding
verb element. For git commit -C HEAD~1, they report two. ShellSyntaxTree still
applies its generic Git flag/path tables, so a command-aware consumer may
reinterpret the latter value as a revision rather than a path. The new API
provides the missing positional evidence; it deliberately does not encode Git
semantics. git --no-pager commit -C HEAD~1 demonstrates why the consumer
cannot use the count alone: --no-pager stops the generic greedy walk, so
commit is an argument element even though Git treats it as the subcommand.
The grammar helper above is also responsible for attached forms and for
binding a spaced flag to the following operand. It enumerates every occurrence,
so a global -C /repo cannot hide a later command-scoped -C HEAD~1.
Raw preserves exact spelling, Value carries the lexer-decoded value, and
SourceStart / SourceLength distinguish repeated occurrences. Existing
Verb, Args, and Redirects remain compatibility conveniences. Synthetic
cwd attribution remains only in Args; elements expanded from a command-string
wrapper have null source spans when they cannot be mapped exactly into the
outer source.
Netclaw is ShellSyntaxTree's original consumer. Its approval gate is a useful production case study, but its policy choices are not part of ShellSyntaxTree's contract.
The links below are immutable references to Netclaw commit
74014139a833050d777fbc913345904cca3b0544:
- Package reference and version pin show the consumer dependency. That snapshot uses ShellSyntaxTree 0.1.5 and therefore demonstrates the POSIX/Bash integration, not the newer PowerShell parser.
- Dependency-injection registration
binds
IShellParsertoBashParser. - Parser construction and safe-fail adaptation supply the invocation working directory and convert unparseable or empty results into "cannot decompose."
- Approval-candidate extraction evaluates every clause and keeps Netclaw's command-specific normalization in the consumer.
- Directory and redirect attribution combine explicit operands, inherited cwd, and redirect targets.
- Approval-unit grouping starts new units for statements while retaining pipeline stages together for display.
- User-facing reconstruction drops synthetic cwd attribution and applies product-specific summarization.
- Fail-closed authorization refuses automatic approval when the parser cannot produce candidates.
- Package integration canaries pin parser registration, verb extraction, compound splitting, cwd attribution, and dynamic-content behavior across package upgrades.
The reusable lesson is the flow: parse with real context, fail safely, inspect every clause, keep derived cwd separate from authored tokens, and layer application policy over parser facts. Netclaw's verb trimming, side-effect classification, path predicate, and approval persistence are intentionally application-specific.
The repository includes two public samples:
ShellSyntaxTree.Cli.Sampleprints the AST and applies a deliberately small audit policy.ShellSyntaxTree.Web.Samplerenders Bash and PowerShell parses as Mermaid diagrams in the browser.
The CLI policy is an illustration, not a production allow-list. It is useful for seeing how a consumer walks paths, dynamic arguments, redirects, and adjacent pipeline clauses. The Netclaw links above show how those primitives fit into a real approval lifecycle.
SPEC.mddefines the shared AST and Bash behavior.SPEC.POWERSHELL.mddefines PowerShell-specific parsing, alias resolution, parameter binding, and resolver behavior.README.mdprovides installation and quick-start examples.