diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 9ee8d37..8c78ebc 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -133,9 +133,24 @@ priorities. as the v0.3 roadmap and cross-link issue #71 control flow and issue #69 shared native argument-fragment classification without merging their scopes. -- [ ] Complete OpenSpec task group 1: lock the additive public type names, - compatibility projection, fixed analysis bounds, and supported-construct - matrix before production implementation. +- [x] Add a versioned pre-implementation design corpus with paired Bash and + PowerShell representative, boundary, and adversarial cases. The validator + rejects schema drift, checks references and command ordering, confirms + every `current` expectation against the v0.2 parsers, and includes the + files in the PII audit. Corpus review established that command role must + be immediate while ancestry remains compositional, occurrence + completeness is independent of value precision, and PowerShell authored + parameter classification must remain distinct from effective values. The + paired 32/33-candidate boundary cases lock finite-versus-unknown behavior. +- [x] Lock OpenSpec task-group decisions 1.1–1.5 and 1.8: exact public type + candidates and safe defaults, in-memory `Clause` identity, fixed 32/16/5 + analysis limits, separate Bash and PowerShell grammar matrices, static + pattern-cover rules, divergent-cwd fallback, deferred forms, project + context, and the preimplementation consumer-guide migration contract. +- [ ] Complete OpenSpec tasks 1.6–1.7 in the public-API implementation PR: + synchronize the accepted shared and PowerShell contracts into + `SPEC.md` / `SPEC.POWERSHELL.md` together with source and snapshot tests + so the repository authority never intentionally drifts from the assembly. - [ ] Implement [issue #69](https://github.com/Aaronontheweb/ShellSyntaxTree/issues/69) as the first behavior-preserving preparation after contract lock. - [ ] Add the structural and command-occurrence projections for the existing @@ -143,6 +158,9 @@ priorities. - [ ] Deliver Bash `for ... in` and PowerShell `foreach` as the first two language-specific vertical slices, then extract only the shared analysis proven by both implementations. +- [ ] Preserve the existing Bash heredoc grammar, fix quoted-delimiter + adjacency, expose body/delimiter/expansion/completeness facts, and add a + separately tested Bash `<<<` here-string redirect slice. --- @@ -167,9 +185,8 @@ priorities. composed helper. - Windows `cmd` parser. - Source-mapping (line/column on AST nodes) — only if an IDE consumer asks. -- Heredoc body preservation and process substitution are separately gated - issue #72 tasks backed by production need; Bash function definitions remain - deferred until a consumer need surfaces. +- Process substitution remains a separately gated issue #72 task; Bash + function definitions remain deferred until a consumer need surfaces. ## Parked diff --git a/PROJECT_CONTEXT.md b/PROJECT_CONTEXT.md index 842081a..1899bae 100644 --- a/PROJECT_CONTEXT.md +++ b/PROJECT_CONTEXT.md @@ -66,7 +66,7 @@ zero-native-deps .NET parser sized to what security gates actually need. ## Scope Discipline -### v0.2 (current prerelease line) +### v0.2 (current stable line) - Bash and PowerShell 7 pipeline parsing ship behind the shared `IShellParser` seam. Windows `cmd` remains deferred. @@ -75,7 +75,27 @@ zero-native-deps .NET parser sized to what security gates actually need. JSON entry parses to its expected AST, and the PowerShell corpus also passes the live `pwsh` oracle matrix. -### Explicit non-goals +### v0.3 (contract design) + +- Add a closed, strongly typed syntax-node hierarchy while retaining existing + `Clause` leaves. +- Add a library-owned command-occurrence projection for security consumers so + every potentially executable iterator, condition, branch, substitution, and + body command is evaluated exactly once. +- Add fixed, non-executing value and state analysis: at most 32 candidates, at + most 16 structural container levels, and the existing wrapper depth of 5. +- Deliver Bash `for ... in` and PowerShell `foreach` first, then the locked + `while` and `if` subsets independently for each shell. Shared lowering and + analysis are extracted only after both front ends prove identical behavior. +- Preserve existing Bash heredocs and add explicit body/expansion facts plus + Bash `<<<` here strings. Keep process substitution, background lists, Bash + `case`, PowerShell `switch`, arithmetic/C-style loops, and definitions + independently gated. +- Treat `openspec/changes/v0-3-structured-shell-analysis/` and its paired design + corpus as the review authority until the accepted contract is synchronized + into `SPEC.md` and `SPEC.POWERSHELL.md` with the production API change. + +### v0.2 explicit non-goals - Command execution. - Variable expansion of any kind (we **mark** dynamic tokens, never resolve @@ -92,12 +112,17 @@ zero-native-deps .NET parser sized to what security gates actually need. provenance for significant clause leaves, not a lossless concrete syntax tree. +The v0.3 scope above deliberately changes only the listed control-flow and +structural items. Execution, runtime variable expansion, full script parsing, +and IDE-grade concrete syntax remain non-goals. + ### Versioning - `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 implementation; alpha and beta.1 shipped, - stable promotion pending downstream validation. +- `0.2.0` — first PowerShell parser implementation; stable. +- `0.3.0` — additive structured syntax, complete command occurrences, explicit + redirect semantics, and bounded control-flow analysis for both shells. - `1.0.0` — at least one external consumer beyond Netclaw ships against it without finding API gaps. @@ -163,6 +188,7 @@ Per SPEC §17, all of the following must be true: | Library source | `src/ShellSyntaxTree/` | | Tests + corpus | `tests/ShellSyntaxTree.Tests/` | | Corpus entries | `tests/ShellSyntaxTree.Tests/Corpus/{bash,powershell}/*.json` | +| v0.3 design corpus | `tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/{bash,powershell}.json` | | The contracts | `SPEC.md`, `SPEC.POWERSHELL.md` | | Consumer guide | `docs/CONSUMER_GUIDE.md` | | Active work plan | `IMPLEMENTATION_PLAN.md` | diff --git a/TOOLING.md b/TOOLING.md index fcdb4ec..70abd2f 100644 --- a/TOOLING.md +++ b/TOOLING.md @@ -30,6 +30,13 @@ directory, routes each entry to the matching parser (`bash/` → `BashParser`, gate — it feeds every PowerShell corpus input to real `pwsh` and enforces the oracle matrix. +`tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/` is a separate, +pre-implementation contract corpus. Its focused validator rejects unknown JSON +members, checks syntax/occurrence references and security invariants, and +compares each recorded `current` result with the real v0.2 parser. Design cases +move into the executable `Corpus//` only when the corresponding v0.3 API +and parser slice exists. The PII audit scans both corpus trees. + ### PwshCorpusTool `tools/PwshCorpusTool` is the PowerShell corpus authoring aid diff --git a/docs/CONSUMER_GUIDE.md b/docs/CONSUMER_GUIDE.md index 15aba51..6c64e44 100644 --- a/docs/CONSUMER_GUIDE.md +++ b/docs/CONSUMER_GUIDE.md @@ -141,6 +141,57 @@ 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. +## Planned v0.3 migration contract + +> This section describes the locked v0.3 design and is not an API available in +> the current v0.2 package. The production example above remains correct until +> a v0.3 prerelease ships. + +v0.3 adds `ParsedCommand.Commands` as the authorization projection and +`ParsedCommand.Syntax` as the typed display/analysis tree. The migration rules +are: + +1. Check `IsUnparseable` first. An unparseable result has empty `Commands` and + `Clauses`; any partial `Syntax` is diagnostic only. +2. Authorize every `CommandOccurrence`, including iterator, condition, branch, + substitution, and loop-body commands. Do not recursively walk `Syntax` to + discover commands. +3. Require `CommandOccurrence.IsComplete`, a recognized `ImmediateRole`, and a + static command identity before considering approval reuse. +4. Preserve authored PowerShell parameter/argument classification, then apply + shell binding and executable-specific grammar to every exact or finite + effective value. A value that begins with `-` can affect a native command; + it does not retroactively become a PowerShell cmdlet parameter token. +5. Evaluate every redirect through its explicit operation, source, target, + path relevance, and completeness. Do not infer descriptor safety from raw + prefixes. +6. Prompt or deny when an unknown value can affect identity, options, path + scope, cwd, or redirects. A structurally complete occurrence may still have + an unknown value; those are separate facts. + +Heredoc and Bash here-string bodies are stdin data, not implicit child commands +or filesystem paths. Authorize any command substitutions surfaced from an +expanding heredoc as normal occurrences, then let executable-specific policy +decide whether the remaining data matters. Complete literal data need not cause +a prompt merely because it uses `<<`, `<<-`, or `<<<`; unknown data passed to a +receiver that interprets stdin as code remains policy-sensitive and fails +closed. + +`ParsedCommand.Clauses` remains as a conservative v0.2 compatibility +projection during migration. For a successful result, the syntax leaf, +occurrence, and compatibility projection share the same in-memory `Clause` +instance. Nested authored commands are flattened in source order, no operator +is invented across structural boundaries, and loop variables remain authored +as dynamic values rather than being silently substituted into compatibility +records. + +The new records change generated equality, hashing, `ToString()`, and default +serialization output. ShellSyntaxTree does not promise a stable serialized +wire format for its closed polymorphic syntax family. Consumers that persist +results should own a versioned DTO or explicit serializer mapping. The full +compiling v0.3 consumer example replaces this preview when the prerelease API +lands. + ## Choosing a command identity For PowerShell aliases, prefer the canonical cmdlet identity while retaining diff --git a/openspec/changes/v0-3-structured-shell-analysis/design.md b/openspec/changes/v0-3-structured-shell-analysis/design.md index 8f59486..10b7bb5 100644 --- a/openspec/changes/v0-3-structured-shell-analysis/design.md +++ b/openspec/changes/v0-3-structured-shell-analysis/design.md @@ -123,19 +123,27 @@ than inventing offsets into escaped or encoded outer text. `Commands` contains one entry per authored simple command that may execute, not one entry per predicted runtime iteration. Each occurrence carries its -`Clause`, structural role, ancestry suitable for diagnostics, and an explicit -completeness fact. Roles include at least ordinary, pipeline stage, condition, -iterator, loop body, branch, and substitution; the final names are locked with -the public API review. +`Clause`, immediate structural role, compositional ancestry suitable for +analysis and diagnostics, and an explicit completeness fact. Immediate roles +include at least ordinary, pipeline stage, condition, iterator, loop body, +branch, and substitution; ancestry frames retain every outer role, such as a +pipeline stage nested inside a loop body. The final names are locked with the +public API review. Condition and iterator commands are never omitted. Mutually exclusive branch commands all appear because the collection is a may-execute set. Runtime loop counts do not duplicate occurrences; bounded variable domains describe the possible effective values at the occurrence. +Completeness and value precision are independent. A structurally complete +occurrence may conservatively contain an `Unknown` value domain when the +command and its ancestry are fully discovered but a runtime value cannot be +proved. + If any executable region cannot be discovered completely, the containing -`ParsedCommand` remains `IsUnparseable=true`. Partial syntax and occurrences -may be returned for diagnostics but MUST NOT be used to authorize execution. +`ParsedCommand` remains `IsUnparseable=true`. Partial syntax may be returned +for diagnostics, but `Commands` and `Clauses` are empty so no authorization +projection exposes a discovered subset. ### Preserve Clauses as a conservative flattened view @@ -171,10 +179,13 @@ shell rules prove the resulting argument boundary. Unquoted Bash expansion, PowerShell object-valued pipelines, indirect expansion, mutation, and cross-product explosion remain unknown until separately specified. -Effective values are shell facts, not executable semantics. A consumer must -re-run its executable-aware option grammar for every exact or finite candidate; -for example, a loop value beginning with `-` may inject an option even if the -authored `$variable` token was not option-shaped. +Effective values are shell facts, not executable semantics. The analysis must +preserve both the authored shell classification and each proved effective +value. PowerShell does not retroactively turn a string value such as `-Force` +into a cmdlet parameter token, while the same value passed to a native +executable may participate in that executable's option grammar. A consumer +must therefore apply the relevant shell binding rules and re-run its complete +executable-aware grammar for every exact or finite candidate. ### Join state rather than selecting a path @@ -257,8 +268,8 @@ corpus remains sanitized under the existing PII audit. structural parsers separate; extract only duplication demonstrated by both working slices. - **[Partial trees invite partial authorization]** -> Keep - `IsUnparseable=true`, mark occurrences incomplete, and state that partial - results are diagnostic only. + `IsUnparseable=true`, return empty `Commands` and `Clauses`, and keep any + partial syntax diagnostic-only. - **[Scope grows to every script construct]** -> Treat heredocs, process substitution, background lists, C-style loops, arithmetic, definitions, and `.ps1` files as separately gated slices. @@ -284,27 +295,56 @@ Before stable 0.3.0, a flawed new surface can be revised with prerelease migration notes. After stable release, removals or renames follow the normal minor-version rule for this 0.x library. `Clauses` is not removed in 0.3. -## Open Questions - -1. What are the exact public names and members for the syntax root, occurrence, - ancestry, value-domain, and redirect-detail types? -2. What fixed candidate-count and nesting limits are small enough for security - review while useful for agent-authored loops? -3. Should an occurrence refer to the identical `Clause` instance used by the - syntax leaf and compatibility projection, or only guarantee value equality? -4. Which initial pattern facts can safely expose a covering directory when - Bash unmatched-glob options are unknown? -5. Is any divergent cwd domain useful in v0.3, or should every disagreement - immediately become `Unknown`? -6. Which heredoc forms, process substitutions, and background-list forms belong - in 0.3 rather than subsequent additive releases? - -## Appendix A: Non-Normative Candidate Public API - -The following sketches make the design review concrete. They are deliberately -non-normative: task group 1 must reconcile names, default values, XML -documentation, serialization behavior, and the exact member set with the five -capability specifications before any public type is implemented. +## Contract-Lock Decisions + +The first paired design-corpus review resolves the original open questions as +follows. These decisions are normative for this change and are synchronized +into the release specifications before production types are added. + +1. Appendix A locks the public type names, members, enum zero values, and + defaults. The syntax hierarchy is a closed record family with an explicit + kind; command discovery remains collection-based so authorization consumers + never need a type switch. +2. A value domain contains at most 32 candidates. Supported structural nesting + is at most 16 container nodes. Existing decoded-command wrapper recursion + remains capped at 5. The limits are public static get-only properties, not + caller-configurable parser options or compile-time constants. Candidate + overflow produces `Unknown`; structural or wrapper-depth overflow makes the + whole result unparseable. +3. Within one successful `ParsedCommand`, a simple-command syntax leaf, its + command occurrence, and its compatibility `Clauses` entry reference the + identical `Clause` instance. This is an in-memory parser-result guarantee, + not a serialization reference-preservation guarantee. +4. The initial `Pattern` domain is limited to a Bash path-shaped glob with no + dynamic root, substitution, indirect expansion, or unresolved parent + traversal. Its `CoveringDirectory` is the exact static directory prefix, + resolved against an exact cwd when relative. The parser never enumerates the + filesystem. Bash unmatched-glob settings can change whether the loop has + zero iterations or yields the literal pattern, but neither outcome escapes + that lexical cover. Every other pattern becomes `Unknown`. +5. v0.3 does not publish a finite cwd domain. Identical branch exits retain an + exact cwd; the first disagreement, unknown mutation, or loop-exit ambiguity + produces `Unknown`. +6. The stable v0.3 grammar includes the existing simple-command grammar, + structural projection, Bash `for ... in`, `while` / `until`, and + `if` / `elif` / `else`, plus PowerShell `foreach`, `while`, and + `if` / `elseif` / `else` within the bounded subsets below. It also preserves + the existing Bash heredoc grammar while adding explicit body, delimiter, + expansion, and completeness facts, and adds Bash `<<<` here strings. + Process substitution, single-`&` background lists, Bash `case`, PowerShell + `switch`, arithmetic/C-style loops, implicit Bash positional-parameter + loops, and function/definition bodies remain independently gated. + +On every unparseable result, `Commands` and the v0.2 `Clauses` projection are +empty. `Syntax` may contain a partial diagnostic tree, but it cannot be used as +authorization evidence. This makes accidental subset authorization harder for +both old and new consumers. + +## Appendix A: Locked Public API Contract + +The following shape is normative for this OpenSpec change. XML documentation +and the public API snapshot must preserve these members and defaults when task +group 1 synchronizes the contract into `SPEC.md` and `SPEC.POWERSHELL.md`. ### Structural node family @@ -316,27 +356,47 @@ public abstract record ShellSyntaxNode // Prevent consumers from extending the parser-owned node family. private protected ShellSyntaxNode() { } + public abstract ShellSyntaxKind Kind { get; } public int? SourceStart { get; init; } public int? SourceLength { get; init; } } +public enum ShellSyntaxKind +{ + Unknown, + Block, + SimpleCommand, + Pipeline, + CommandList, + Group, + ForEach, + ConditionLoop, + Conditional, + ConditionalBranch, + CommandSubstitution, +} + public sealed record ShellBlockSyntax : ShellSyntaxNode { + public override ShellSyntaxKind Kind => ShellSyntaxKind.Block; public IReadOnlyList Statements { get; init; } = []; } public sealed record SimpleCommandSyntax : ShellSyntaxNode { + public override ShellSyntaxKind Kind => ShellSyntaxKind.SimpleCommand; public Clause Clause { get; init; } = new(); } public sealed record PipelineSyntax : ShellSyntaxNode { + public override ShellSyntaxKind Kind => ShellSyntaxKind.Pipeline; public IReadOnlyList Stages { get; init; } = []; } public sealed record CommandListSyntax : ShellSyntaxNode { + public override ShellSyntaxKind Kind => ShellSyntaxKind.CommandList; public IReadOnlyList Items { get; init; } = []; } @@ -348,44 +408,88 @@ public sealed record CommandListItemSyntax public sealed record GroupSyntax : ShellSyntaxNode { - public ShellGroupKind Kind { get; init; } + public override ShellSyntaxKind Kind => ShellSyntaxKind.Group; + public ShellGroupKind GroupKind { get; init; } public ShellBlockSyntax Body { get; init; } = new(); } +public enum ShellGroupKind +{ + Unknown, + CurrentScope, + IsolatedScope, +} + public sealed record ForEachSyntax : ShellSyntaxNode { + public override ShellSyntaxKind Kind => ShellSyntaxKind.ForEach; public LoopBindingSyntax Binding { get; init; } = new(); - public ShellValueExpressionSyntax Iterable { get; init; } = new(); + public ShellSourceFragment Iterable { get; init; } = new(); public ShellBlockSyntax IteratorCommands { get; init; } = new(); public ShellBlockSyntax Body { get; init; } = new(); } +public sealed record LoopBindingSyntax +{ + public string Name { get; init; } = ""; + public ShellSourceFragment Source { get; init; } = new(); +} + +public sealed record ShellSourceFragment +{ + public string Raw { get; init; } = ""; + public int? SourceStart { get; init; } + public int? SourceLength { get; init; } +} + public sealed record ConditionLoopSyntax : ShellSyntaxNode { - public ConditionLoopKind Kind { get; init; } + public override ShellSyntaxKind Kind => ShellSyntaxKind.ConditionLoop; + public ConditionLoopKind LoopKind { get; init; } public ShellBlockSyntax Condition { get; init; } = new(); public ShellBlockSyntax Body { get; init; } = new(); } +public enum ConditionLoopKind +{ + Unknown, + While, + Until, +} + public sealed record ConditionalSyntax : ShellSyntaxNode { - public ShellBlockSyntax Condition { get; init; } = new(); - public ShellBlockSyntax Then { get; init; } = new(); - public IReadOnlyList ElseIf { get; init; } = []; + public override ShellSyntaxKind Kind => ShellSyntaxKind.Conditional; + public IReadOnlyList Branches { get; init; } = []; public ShellBlockSyntax? Else { get; init; } } + +public sealed record ConditionalBranchSyntax : ShellSyntaxNode +{ + public override ShellSyntaxKind Kind => ShellSyntaxKind.ConditionalBranch; + public ShellBlockSyntax Condition { get; init; } = new(); + public ShellBlockSyntax Body { get; init; } = new(); +} + +public sealed record CommandSubstitutionSyntax : ShellSyntaxNode +{ + public override ShellSyntaxKind Kind => ShellSyntaxKind.CommandSubstitution; + public ShellBlockSyntax Body { get; init; } = new(); +} ``` -`ForEachSyntax` is shown as a shared execution-structure node, not a claim that -Bash words and PowerShell expressions share a grammar. If the contract review -shows that their iterable or binding facts cannot coexist without optional -members or semantic ambiguity, the public family should instead use -`BashForEachSyntax` and `PwshForEachSyntax` derived from a smaller common loop -base. +`ForEachSyntax` shares only proved execution structure. `Iterable.Raw` preserves +the shell-specific authored expression without claiming that Bash words and +PowerShell expressions share a grammar; `IteratorCommands` separately exposes +commands discovered inside that expression. Bounded values live on command +occurrences, not on this display tree. Internal parse nodes and expression +adapters remain shell-specific. -The same rule applies to `ConditionalSyntax`: share the shape only when the -public fields preserve every material shell distinction. Internal parse nodes -remain shell-specific regardless of the eventual public choice. +Every enum introduced in v0.3 reserves zero as `Unknown`, except existing v0.2 +enums whose zero values are already locked. Consumers fail closed on `Unknown` +or an unrecognized numeric value. The closed base constructor prevents external +syntax-node implementations; later library versions may add derived records, +so authorization code still needs a default fail-closed type-switch arm. ### Command occurrence and bounded values @@ -393,7 +497,7 @@ remain shell-specific regardless of the eventual public choice. public sealed record CommandOccurrence { public Clause Clause { get; init; } = new(); - public CommandOccurrenceRole Role { get; init; } + public CommandOccurrenceRole ImmediateRole { get; init; } public IReadOnlyList Ancestry { get; init; } = []; public IReadOnlyList EffectiveArguments { get; init; } = []; public ShellValueDomain WorkingDirectory { get; init; } = ShellValueDomain.Unknown; @@ -403,6 +507,7 @@ public sealed record CommandOccurrence public enum CommandOccurrenceRole { + Unknown, Ordinary, PipelineStage, Condition, @@ -412,10 +517,33 @@ public enum CommandOccurrenceRole Substitution, } +public sealed record CommandAncestryFrame +{ + public ShellSyntaxKind AncestorKind { get; init; } + public CommandAncestryRegion Region { get; init; } + public int? ChildIndex { get; init; } + public int? SourceStart { get; init; } + public int? SourceLength { get; init; } +} + +public enum CommandAncestryRegion +{ + Unknown, + Root, + Statement, + PipelineStage, + GroupBody, + Iterator, + LoopBody, + Condition, + Branch, + Substitution, +} + public sealed record EffectiveArgument { // A stable authored coordinate is safer than correlating by string value. - public int ClauseElementIndex { get; init; } + public int ClauseElementIndex { get; init; } = -1; public ShellValueDomain Value { get; init; } = ShellValueDomain.Unknown; } @@ -436,29 +564,88 @@ public enum ShellValueDomainKind FiniteSet, Pattern, } + +public static class ShellAnalysisLimits +{ + public static int MaxValueCandidates => 32; + public static int MaxStructuralNesting => 16; + public static int MaxWrapperRecursionDepth => 5; +} ``` -The candidate uses a source-authored element coordinate rather than attaching +`Ancestry` is ordered outermost to innermost and contains structural containers, +not the `SimpleCommandSyntax` leaf itself. `ChildIndex` disambiguates repeated +regions such as a pipeline stage or conditional branch. `ImmediateRole` +describes the nearest execution relation; ancestry retains outer relations. + +The contract uses a source-authored element coordinate rather than attaching derived values directly to `Arg`. This prevents a loop iteration from mutating the compatibility leaf and provides a place for one authored token to have -multiple possible effective values. Contract review must still account for -redirect operands, inline option bindings, shell expansions that create more -than one argument, and occurrences that do not have an exact outer source span. +multiple possible effective values. Redirect operands use the separate redirect +coordinate below. Inline option bindings retain their one authored +`ClauseElement`; shell expansions that might create more than one argument are +`Unknown` until their boundaries can be proved. An occurrence lifted from a +wrapper may therefore have a valid clause-element index even when that +element's outer source span is unavailable. +An `Unknown` value at one of those coordinates does not by itself make the +occurrence structurally incomplete. + +The parser emits only valid value-domain combinations: + +- `Unknown`: no values, pattern, or covering directory; +- `Exact`: exactly one value and no pattern fields; +- `FiniteSet`: 2–32 distinct values and no pattern fields; +- `Pattern`: no values, a non-empty pattern, and a non-empty covering directory. + +Any internally invalid combination is a parser bug. A consumer reading an +externally persisted or reconstructed instance fails closed rather than trying +to repair it. ### Explicit redirect facts ```csharp public sealed record RedirectAnalysis { - public int RedirectIndex { get; init; } - public int? SourceDescriptor { get; init; } + public int RedirectIndex { get; init; } = -1; + public RedirectSource Source { get; init; } = new(); public RedirectOperation Operation { get; init; } public int? TargetDescriptor { get; init; } public ShellValueDomain Target { get; init; } = ShellValueDomain.Unknown; + public HereDocumentAnalysis? HereDocument { get; init; } public bool IsPathRelevant { get; init; } public bool IsComplete { get; init; } } +public sealed record HereDocumentAnalysis +{ + public ShellSourceFragment Delimiter { get; init; } = new(); + public ShellSourceFragment Body { get; init; } = new(); + public HereDocumentExpansionMode ExpansionMode { get; init; } + public bool StripLeadingTabs { get; init; } + public bool IsComplete { get; init; } +} + +public enum HereDocumentExpansionMode +{ + Unknown, + Literal, + Expand, +} + +public sealed record RedirectSource +{ + public RedirectSourceKind Kind { get; init; } + public int? Descriptor { get; init; } +} + +public enum RedirectSourceKind +{ + Unknown, + Default, + Descriptor, + PowerShellAllStreams, +} + public enum RedirectOperation { Unknown, @@ -475,12 +662,22 @@ public enum RedirectOperation } ``` -This sketch places occurrence-specific redirect analysis on -`CommandOccurrence` and leaves the existing `Redirect` record untouched. An -alternative is an additive `Redirect.Analysis` property. The contract review -should prefer the shape that avoids duplicated facts while preserving v0.2 -equality and serialization expectations as far as an additive record change -allows. +Occurrence-specific redirect analysis stays on `CommandOccurrence`; the +existing `Redirect` record remains untouched. This is necessary because a loop +can give one authored redirect target several effective values, while mutating +`Redirect` would also change v0.2 equality and serialization. `RedirectIndex` +correlates to `Clause.Redirects`. `RedirectSource` represents the operator's +default stream, a numeric Bash/PowerShell descriptor, or PowerShell's `*` +selector without losing shell identity. Invalid source-kind/descriptor +combinations are incomplete and fail closed. + +`HereDocument` is non-null only for `HereDocument` operations and preserves the +authored delimiter and body independently. `Literal` means delimiter quoting +disables body expansion; `Expand` means every execution-bearing substitution +must be discovered and surfaced before the redirect can be complete. +`HereString` specifically represents Bash `<<<`; its operand and exact, finite, +or unknown effective data use `Target`. PowerShell `@"..."@` and `@'...'@` +here-strings remain ordinary PowerShell value tokens, not redirects. ### ParsedCommand composition and consumer entry point @@ -500,6 +697,18 @@ public sealed record ParsedCommand } ``` +For a successful result, each `SimpleCommandSyntax.Clause`, matching +`CommandOccurrence.Clause`, and matching entry in `Clauses` is reference-equal. +For an unparseable result, `Commands` and `Clauses` are empty even when `Syntax` +contains partial diagnostics. + +The new records participate in generated record equality, hashing, and +`ToString()`, and the new `ParsedCommand` members change those generated results. +The library does not define a stable JSON wire format and does not add serializer +attributes or a serialization dependency for the polymorphic syntax family. +Consumers that persist parser results must own a versioned DTO or configure +their serializer explicitly; ordinary in-memory consumers use the typed API. + The intended security-consumer shape is therefore: ```csharp @@ -511,26 +720,29 @@ if (parsed.IsUnparseable || parsed.Commands.Count == 0) foreach (var occurrence in parsed.Commands) { - if (!occurrence.IsComplete || occurrence.Clause.Verb.IsDynamic) + if (!occurrence.IsComplete + || occurrence.ImmediateRole == CommandOccurrenceRole.Unknown + || occurrence.Clause.Verb.IsDynamic) { return Prompt("command execution is not statically bounded"); } - var interpreted = executableGrammar.Interpret(occurrence); + var interpreted = executableGrammar.InterpretAuthoredShellShape(occurrence); if (!interpreted.IsComplete) { return Prompt("executable arguments are ambiguous"); } EvaluateEveryCandidate(interpreted); + EvaluateEveryRedirect(occurrence.Redirects); } ``` -## Appendix B: Non-Normative Grammar and Parser Mocks +## Appendix B: Locked Grammar Boundaries and Non-Normative Parser Mocks -These sketches show how the existing flat parsers can evolve. They are not a -replacement for the normative BNF that task group 1 adds to `SPEC.md` and -`SPEC.POWERSHELL.md`. +The BNF and support matrices in this appendix lock the v0.3 boundary. The C# +parser sketches show one implementation route and remain non-normative. Task +group 1 synchronizes the BNF into `SPEC.md` and `SPEC.POWERSHELL.md`. ### Shared structural vocabulary, not a shared grammar @@ -551,7 +763,7 @@ This vocabulary describes output relationships only. Each shell defines its own token boundaries, contextual keywords, expression forms, terminators, scope, and recovery rules. -### Candidate Bash grammar delta +### Locked Bash grammar delta The initial Bash slice extends the current `command := clause (compound_op clause)*` grammar into recursive command lists. Only contextual @@ -564,34 +776,59 @@ bash_list_item := bash_and_or bash_and_or := bash_pipeline (("&&" | "||") bash_pipeline)* bash_pipeline := bash_command ("|" bash_command)* bash_command := bash_for_in + | bash_condition_loop + | bash_if | bash_group | bash_subshell | bash_c_wrapper | bash_simple_command -// Initial v0.3 tracer bullet: explicit `in` form only. +// Explicit `in` form only. bash_for_in := "for" binding_name "in" iterable_word* list_terminator "do" bash_script(stop = "done") "done" +bash_condition_loop := ("while" | "until") + bash_script(stop = "do") "do" + bash_script(stop = "done") "done" + +bash_if := "if" bash_script(stop = "then") "then" + bash_script(stop = "elif" | "else" | "fi") + bash_elif* bash_else? "fi" +bash_elif := "elif" bash_script(stop = "then") "then" + bash_script(stop = "elif" | "else" | "fi") +bash_else := "else" bash_script(stop = "fi") + list_sep := ";" | NEWLINE list_terminator := ";" | NEWLINE+ binding_name := shell_identifier iterable_word := word | quoted_string | supported_substitution ``` -Later Bash deltas add `while` / `until`, `if` / `elif` / `else`, and `case` -using explicit stop-keyword sets. C-style `for ((...))`, implicit `for name` -iteration over positional parameters, arithmetic expansion, and substitutions -whose inner commands cannot be discovered remain rejected until separately -specified. +Stop keywords are contextual and match only at command position after a list +separator. C-style `for ((...))`, implicit `for name` iteration over positional +parameters, `case`, arithmetic execution, and substitutions whose inner +commands cannot be discovered remain rejected until separately specified. The current Bash lexer already emits `for`, `in`, `do`, and `done` as `Word` tokens. The first slice therefore does not require dedicated keyword token kinds. The structural parser interprets them contextually and preserves the existing lexer values and spans. +| Bash construct | Stable v0.3 status | +|---|---| +| Existing simple commands, `&&`, `||`, `;`, pipelines, groups, subshells, and static command-string wrappers | Supported and structurally projected | +| `for name in words; do ...; done` | Supported | +| `while` / `until` command lists | Supported | +| `if` / `elif` / `else` command lists | Supported | +| Completely delimited command substitution in a supported iterable | Inner commands visible; produced value `Unknown` | +| Static path-shaped glob in a supported iterable | `Pattern` only under the locked covering-directory rule | +| Existing `<<` / `<<-` heredocs | Supported; preserve delimiter, body, expansion mode, and completeness without treating body data as commands | +| Bash `<<<` here strings | Supported with explicit here-string redirect facts | +| Process substitution and single-`&` background lists | Independently gated; whole result unparseable until supported | +| `case`, C-style or implicit loops, functions, arithmetic execution | Deferred; whole result unparseable when execution may be hidden | + ### Candidate Bash recursive-descent flow ```csharp @@ -654,7 +891,7 @@ All `Expect*` failures return one outer unparseable result. They do not skip to `done` and return a partial tree that could be mistaken for authorization evidence. -### Candidate PowerShell grammar delta +### Locked PowerShell grammar delta PowerShell retains its statement-versus-pipeline distinction and contextual keyword rules. In particular, `foreach` is a language keyword only at a @@ -664,12 +901,21 @@ continues to treat `foreach` as command or alias syntax. ```text pwsh_script(stop) := pwsh_statement (statement_sep pwsh_statement)* pwsh_statement := pwsh_foreach + | pwsh_while + | pwsh_if | pwsh_pipeline pwsh_foreach := "foreach" "(" variable "in" foreach_expression ")" script_block_body -// Initial v0.3 tracer bullet only. +pwsh_while := "while" "(" condition_pipeline ")" + script_block_body + +pwsh_if := "if" "(" condition_pipeline ")" script_block_body + pwsh_elseif* pwsh_else? +pwsh_elseif := "elseif" "(" condition_pipeline ")" script_block_body +pwsh_else := "else" script_block_body + foreach_expression := literal_value | literal_array | pipeline_expression @@ -733,6 +979,22 @@ as a statement body. This avoids accidentally executing or authorizing the contents of `ForEach-Object { ... }`, arbitrary script-block arguments, or a dynamic call operator. +`condition_pipeline` is limited to a pipeline that the existing command parser +can delimit completely. Pure literal and comparison expressions may be +preserved as non-executable condition syntax, but any subexpression, member +invocation, script block, or other form that can execute while escaping +complete command discovery makes the whole result unparseable. + +| PowerShell construct | Stable v0.3 status | +|---|---| +| Existing simple commands, pipelines, statement separators, grouping, and static wrapper / `Invoke-Expression` recursion | Supported and structurally projected | +| `foreach ($name in expression) { ... }` for literal scalar, literal array, or fully delimited pipeline iterables | Supported | +| `while (condition_pipeline) { ... }` | Supported | +| `if` / `elseif` / `else` with fully delimited condition pipelines | Supported | +| Pipeline-produced iterator objects | Iterator commands visible; produced values `Unknown` | +| `ForEach-Object` / `foreach` alias script blocks and ordinary script-block arguments | Existing opaque argument; no invented child execution | +| `do`, `switch`, functions, definitions, class/type bodies, or execution-bearing expressions outside the locked subset | Deferred; whole result unparseable when execution may be hidden | + ### Candidate internal nodes and lowering pipeline The internal tree may retain shell-specific syntax even if the reviewed public @@ -789,7 +1051,7 @@ lattice. Executable-aware interpretation still occurs only in the consumer. | Input condition | Structural result | Authorization-facing result | |---|---|---| -| Missing Bash `do` or `done` | Parse failure with the offending range | `IsUnparseable=true`; partial nodes diagnostic only | +| Missing Bash `do` or `done` | Parse failure with the offending range | `IsUnparseable=true`; `Commands` and `Clauses` empty | | Bash keyword used as an argument | Existing simple-command leaf | No false control-flow node | | Unsupported Bash substitution in an iterable | Inner commands surfaced only if completely parsed | Otherwise the entire result is unparseable | | PowerShell `foreach` at statement position followed by `(` | `PwshForEachNode` | Iterator and body occurrences exposed | @@ -797,4 +1059,4 @@ lattice. Executable-aware interpretation still occurs only in the consumer. | PowerShell statement body `ScriptBlock` | Interior recursively parsed with adjusted spans | Every body command exposed | | PowerShell script block used as an ordinary argument | Existing opaque argument | `DynamicSkip`; contents are not invented as executed commands | | Candidate cap or state-join overflow | Structure remains parseable | Affected effective fact becomes `Unknown` | -| Any executable region is skipped or cannot be delimited | Partial diagnostic tree allowed | `IsUnparseable=true`; no authorization from the subset | +| Any executable region is skipped or cannot be delimited | Partial diagnostic tree allowed | `IsUnparseable=true`; `Commands` and `Clauses` empty | diff --git a/openspec/changes/v0-3-structured-shell-analysis/proposal.md b/openspec/changes/v0-3-structured-shell-analysis/proposal.md index e70323a..9aa9bf3 100644 --- a/openspec/changes/v0-3-structured-shell-analysis/proposal.md +++ b/openspec/changes/v0-3-structured-shell-analysis/proposal.md @@ -28,11 +28,15 @@ fail-closed behavior for incomplete analysis. - Preserve `Clause`, `Arg`, `Redirect`, `ClauseElement`, `VerbChain`, and `ParsedCommand.Clauses` as compatibility projections. Existing consumers that check `IsUnparseable` continue to fail closed and do not silently miss - nested executable commands. + nested executable commands. Unparseable results expose no command or clause + authorization projection. - Expand grammar in vertical slices: Bash `for ... in`, PowerShell `foreach`, - then condition loops and branches. Heredocs, process substitution, - background lists, C-style loops, and arithmetic remain independently gated - by explicit executable-region and value-semantics requirements. + then the locked condition loops and branches. Preserve the existing Bash + heredoc grammar while adding body, delimiter, and expansion facts, and add + Bash `<<<` here-string semantics. Process substitution, background lists, + C-style loops, arithmetic, Bash `case`, and PowerShell `switch` remain + independently gated by explicit executable-region and value-semantics + requirements. - Keep executable-specific option and operand interpretation, authorization policy, and durable approval scope consumer-owned. - Update `docs/CONSUMER_GUIDE.md` and the README usage path so security gates diff --git a/openspec/changes/v0-3-structured-shell-analysis/specs/bounded-shell-analysis/spec.md b/openspec/changes/v0-3-structured-shell-analysis/specs/bounded-shell-analysis/spec.md index caf3529..78c22e7 100644 --- a/openspec/changes/v0-3-structured-shell-analysis/specs/bounded-shell-analysis/spec.md +++ b/openspec/changes/v0-3-structured-shell-analysis/specs/bounded-shell-analysis/spec.md @@ -5,6 +5,11 @@ The analysis SHALL classify a policy-relevant shell value as exact, finite, bounded symbolic pattern, or unknown, and SHALL NOT present a weaker proof as a stronger domain. +`Unknown` SHALL contain no values or pattern fields. `Exact` SHALL contain one +value. `FiniteSet` SHALL contain 2–32 distinct values. `Pattern` SHALL contain +no values and SHALL contain a non-empty pattern and covering directory. The +parser SHALL NOT emit any other member combination. + #### Scenario: One literal value - **WHEN** a loop binds a variable from the single literal `a.txt` - **THEN** the binding domain is exact with value `a.txt` @@ -19,19 +24,56 @@ stronger domain. ### Requirement: Analysis is bounded and non-executing The parser SHALL NOT execute commands, enumerate filesystem matches, inspect -runtime shell variables, or expand candidate combinations beyond a fixed -documented bound. Any value exceeding the bound SHALL become unknown. +runtime shell variables, or expand a value domain beyond 32 candidates. A +domain of 32 candidates remains finite; a domain that would contain 33 or more +becomes unknown rather than being truncated. #### Scenario: Glob is not enumerated - **WHEN** Bash parses `for f in /tmp/*.txt; do rm -- "$f"; done` - **THEN** the parser does not read `/tmp` -- **THEN** it may expose a pattern with conservative covering directory `/tmp` +- **THEN** it exposes a pattern with conservative covering directory `/tmp` + +#### Scenario: Dynamic glob root is unknown +- **WHEN** Bash parses `for f in "$ROOT"/*.txt; do rm -- "$f"; done` +- **THEN** the dynamic root prevents a static covering-directory proof +- **THEN** the iterable value is unknown #### Scenario: Candidate cross product exceeds the limit -- **WHEN** combining finite values would exceed the locked candidate cap +- **WHEN** combining finite values would produce 33 candidates - **THEN** the resulting domain is unknown - **THEN** the parser does not truncate the set and call the truncated result complete +#### Scenario: Candidate cross product reaches the limit +- **WHEN** combining finite values produces exactly 32 candidates +- **THEN** the resulting domain may remain finite and complete + +### Requirement: Structural analysis has fixed depth limits +The parser SHALL support at most 16 nested executable containers and at most 5 +decoded command-string wrapper recursions. Structural depth starts at zero for +the root and increments once when entering a foreach loop, condition loop, +conditional, group, or command substitution. Blocks, conditional-branch +records, command lists, pipelines, and simple-command leaves do not increment +the depth independently. These bounds SHALL NOT be caller-configurable. +Exceeding either bound SHALL make the whole result unparseable rather than +returning an authorization projection for a subset. + +The limits SHALL be exposed as static get-only properties rather than public +compile-time constants so downstream assemblies read the installed parser's +contract instead of inlining stale values. + +#### Scenario: Structural nesting reaches the limit +- **WHEN** a supported input enters exactly 16 nested executable containers +- **THEN** the input remains structurally eligible for complete analysis + +#### Scenario: Structural nesting exceeds the limit +- **WHEN** a seventeenth nested executable container is entered +- **THEN** the result is unparseable +- **THEN** command and compatibility projections are empty + +#### Scenario: Existing wrapper recursion limit remains fixed +- **WHEN** a sixth decoded command-string wrapper would be entered +- **THEN** the result is unparseable under the existing wrapper-depth rule + ### Requirement: Variable substitution preserves argument-boundary uncertainty A loop binding SHALL affect an effective command value only when the selected shell's quoting and expansion rules prove the resulting argument boundaries. @@ -48,10 +90,11 @@ shell's quoting and expansion rules prove the resulting argument boundaries. - **WHEN** a PowerShell `foreach` variable may hold objects emitted by a pipeline - **THEN** its effective string or path value is unknown -### Requirement: Executable semantics are reapplied after substitution -ShellSyntaxTree SHALL preserve effective candidate values without claiming -whether they are options, operands, subcommands, revisions, or paths for a -particular executable. Consumers SHALL interpret every candidate through a +### Requirement: Shell and executable semantics are reapplied after substitution +ShellSyntaxTree SHALL preserve the authored shell classification together with +effective candidate values without claiming whether native-command candidates +are options, operands, subcommands, revisions, or paths. Consumers SHALL apply +the shell's binding rules and interpret every native candidate through a complete executable-aware grammar before reusing authorization. #### Scenario: Finite value injects an rm option @@ -64,6 +107,16 @@ complete executable-aware grammar before reusing authorization. - **THEN** the authored `--` remains visible before the effective candidate - **THEN** the consumer may account for it using rm semantics +#### Scenario: PowerShell cmdlet parameter-like value +- **WHEN** PowerShell parses `foreach ($value in '-Force') { Write-Output $value }` +- **THEN** the authored variable argument remains a positional expression +- **THEN** the effective string `-Force` is not retroactively classified as a cmdlet parameter token + +#### Scenario: PowerShell native option-like value +- **WHEN** PowerShell parses `foreach ($value in '--force') { git clean $value }` +- **THEN** the authored variable argument remains distinct from its effective value +- **THEN** the consumer applies the native executable grammar to `--force` + ### Requirement: Control-flow state joins conservatively Working-directory and supported variable state SHALL be propagated through sequential regions and joined across branches and loop exits. Disagreement @@ -72,7 +125,11 @@ SHALL never be resolved by arbitrarily choosing one path. #### Scenario: Branch-dependent cwd - **WHEN** one branch changes cwd to `/a` and another changes cwd to `/b` - **THEN** a following relative path is not resolved solely under `/a` or solely under `/b` -- **THEN** the cwd is unknown unless a bounded multi-state contract is explicitly supported +- **THEN** the cwd is unknown because v0.3 does not publish divergent cwd alternatives + +#### Scenario: Identical branch cwd +- **WHEN** every supported branch exits with the same exact cwd +- **THEN** the joined cwd remains exact #### Scenario: Zero-iteration loop path - **WHEN** a loop may execute zero times and its body changes cwd diff --git a/openspec/changes/v0-3-structured-shell-analysis/specs/consumer-compatibility/spec.md b/openspec/changes/v0-3-structured-shell-analysis/specs/consumer-compatibility/spec.md index dffdf4f..fa5ce30 100644 --- a/openspec/changes/v0-3-structured-shell-analysis/specs/consumer-compatibility/spec.md +++ b/openspec/changes/v0-3-structured-shell-analysis/specs/consumer-compatibility/spec.md @@ -15,15 +15,21 @@ nested condition, iterator, branch, body, and substitution commands. - **THEN** `Clause.Operator` represents only an actual authored operator relationship - **THEN** no synthetic `Sequence`, `AndIf`, `OrIf`, or `Pipe` relationship is invented +#### Scenario: Projections share one Clause instance +- **WHEN** a fully parseable simple command appears in syntax, command, and compatibility projections +- **THEN** all three projections reference the identical in-memory `Clause` instance +- **THEN** serialization is not required to preserve that reference identity + ### Requirement: Unparseable results are never authorization evidence -All syntax, occurrence, clause, value, and redirect facts SHALL be treated as -partial diagnostic evidence when `ParsedCommand.IsUnparseable=true`. The -consumer guide SHALL require prompt or deny before evaluating them for allow. +When `ParsedCommand.IsUnparseable=true`, `Commands` and `Clauses` SHALL be +empty. `Syntax` MAY contain partial diagnostic evidence, and the consumer guide +SHALL require prompt or deny without using that tree for allow. #### Scenario: Partial body discovered before unsupported syntax - **WHEN** the parser discovers a command in a body but later encounters an unsupported executable region - **THEN** the result remains unparseable -- **THEN** a consumer does not authorize from the discovered subset +- **THEN** command and compatibility projections are empty +- **THEN** a consumer does not authorize from the partial syntax tree ### Requirement: Security consumers authorize command occurrences The consumer guide SHALL direct v0.3 security consumers to evaluate every @@ -55,6 +61,29 @@ position. - **WHEN** a consumer encounters a syntax, role, domain, or redirect kind it does not recognize - **THEN** it fails closed for authorization +### Requirement: Non-path redirect data does not create implicit approval scope +The consumer guide SHALL distinguish complete heredoc and here-string data from +command occurrences and filesystem redirect targets. A consumer SHALL NOT +prompt solely because complete non-path data uses heredoc or here-string shell +syntax. It SHALL still apply executable-specific policy to determine whether +stdin data affects authorization, and unknown data in such a sensitive position +SHALL prompt or deny. + +#### Scenario: Literal data sent to a non-interpreting command +- **WHEN** a complete literal heredoc or here string feeds a command whose stdin is not policy-sensitive +- **THEN** the consumer evaluates the receiving command and any independent path redirects +- **THEN** it need not create a separate command or path approval for the data body + +#### Scenario: Receiver interprets stdin as code +- **WHEN** a command such as a shell interpreter receives unknown here-string data +- **THEN** an executable-aware consumer treats that stdin position as policy-sensitive +- **THEN** the unknown value prompts or denies rather than reusing a broader approval + +#### Scenario: Expanding heredoc executes a substitution +- **WHEN** an expanding heredoc contains a supported command substitution +- **THEN** the substitution is authorized as its own command occurrence +- **THEN** the remaining body is still data rather than an invented child command + ### Requirement: Proven candidates are interpreted individually For every exact or finite effective value, the consumer guide SHALL require the consumer to reapply executable-specific option and operand semantics. A @@ -79,3 +108,15 @@ effects, and the period during which `Clauses` remains supported. - **WHEN** Netclaw migrates to the occurrence and explicit redirect APIs - **THEN** integration tests cover ordinary commands, static fd operations, bounded loops, and unknown-value fallback - **THEN** its temporary raw-prefix redirect workaround can be removed + +### Requirement: Persistence is consumer-versioned +ShellSyntaxTree SHALL define an in-memory typed API but SHALL NOT claim a stable +serialized wire format for polymorphic syntax nodes. The consumer guide SHALL +document generated record equality, hashing, `ToString()`, and default +serialization changes, and SHALL direct persistence consumers to versioned DTOs +or explicit serializer configuration. + +#### Scenario: Consumer persists parser results +- **WHEN** a consumer needs to store or transmit a v0.3 parser result +- **THEN** it does not assume the closed record hierarchy is an implicit stable JSON union +- **THEN** it owns an explicit versioned representation or serializer mapping diff --git a/openspec/changes/v0-3-structured-shell-analysis/specs/executable-command-projection/spec.md b/openspec/changes/v0-3-structured-shell-analysis/specs/executable-command-projection/spec.md index 94141d5..480e894 100644 --- a/openspec/changes/v0-3-structured-shell-analysis/specs/executable-command-projection/spec.md +++ b/openspec/changes/v0-3-structured-shell-analysis/specs/executable-command-projection/spec.md @@ -16,8 +16,14 @@ of whether the command is top-level or nested. - **THEN** its possible effective values are represented by analysis facts rather than three duplicated occurrences ### Requirement: Occurrences identify structural execution roles -Each command occurrence SHALL identify the structural role by which it may -execute and SHALL retain enough ancestry for diagnostics and UI grouping. +Each command occurrence SHALL identify its immediate structural execution role +and SHALL retain compositional ancestry for analysis, diagnostics, and UI +grouping. + +`CommandOccurrenceRole.Unknown` and `CommandAncestryRegion.Unknown` SHALL be +their enum zero values. Ancestry SHALL be ordered outermost to innermost, +exclude the simple-command leaf, and retain child indices and exact-or-null +source ranges for correlation. #### Scenario: While condition and body roles - **WHEN** Bash parses `while curl URL; do sleep 1; done` @@ -29,6 +35,11 @@ execute and SHALL retain enough ancestry for diagnostics and UI grouping. - **THEN** `Get-ChildItem` is identified as an iterator occurrence - **THEN** `Remove-Item` is identified as a loop-body occurrence +#### Scenario: Pipeline stage nested in a loop body +- **WHEN** Bash parses `for f in a b; do printf '%s\n' "$f" | sort; done` +- **THEN** `printf` and `sort` have the immediate role pipeline stage +- **THEN** their ancestry also identifies the enclosing loop body + ### Requirement: Iterator and substitution commands remain visible The parser SHALL include every inner command from a supported executable iterator, substitution, or nested command in the occurrence collection even @@ -58,6 +69,11 @@ be sufficient authorization evidence. - **THEN** the occurrence is structurally complete - **THEN** completeness does not imply that `echo` is authorized +#### Scenario: Complete occurrence with unknown value +- **WHEN** PowerShell parses `foreach ($item in Get-ChildItem) { Write-Output $item }` +- **THEN** the `Write-Output` occurrence may be structurally complete +- **THEN** its effective `$item` value remains unknown because pipeline objects are not evaluated + ### Requirement: Source order is deterministic The occurrence collection SHALL be ordered by authored command occurrence, including commands nested in headers and bodies, with a documented tie-breaker diff --git a/openspec/changes/v0-3-structured-shell-analysis/specs/explicit-redirect-semantics/spec.md b/openspec/changes/v0-3-structured-shell-analysis/specs/explicit-redirect-semantics/spec.md index d8207dd..ee2f296 100644 --- a/openspec/changes/v0-3-structured-shell-analysis/specs/explicit-redirect-semantics/spec.md +++ b/openspec/changes/v0-3-structured-shell-analysis/specs/explicit-redirect-semantics/spec.md @@ -7,6 +7,16 @@ input, file output, append, descriptor duplicate, descriptor close, descriptor move, combined output, and any separately supported here-document or here-string form. +The redirect source SHALL distinguish the shell-default stream, a numeric +descriptor, and PowerShell's all-streams selector. `Unknown` SHALL be the zero +source kind and operation, and invalid source-kind/descriptor combinations +SHALL be incomplete. + +#### Scenario: PowerShell all-streams redirect +- **WHEN** PowerShell parses `Get-ChildItem *> output.txt` +- **THEN** the source is explicitly PowerShell all streams +- **THEN** the target is a path-relevant file output rather than a guessed numeric descriptor + #### Scenario: Static descriptor duplication - **WHEN** Bash parses `dotnet test 2>&1` - **THEN** the redirect operation is descriptor duplicate @@ -74,8 +84,9 @@ facts. New facts SHALL NOT silently reinterpret a dynamic target as static. - **THEN** the v0.3 consumer can distinguish the operation without parsing that raw value ### Requirement: Heredoc bodies are data with explicit expansion facts -When heredoc support is enabled, the redirect model SHALL preserve delimiter -quoting, body source, expansion mode, and analysis completeness. It SHALL NOT +v0.3 SHALL preserve the existing Bash `<<` and `<<-` grammar while adding +delimiter spelling and span, body spelling and span, literal-versus-expanding +mode, tab-stripping mode, and analysis completeness. It SHALL NOT classify the body itself as a child command merely because the receiving executable may interpret that data as code. @@ -84,7 +95,42 @@ executable may interpret that data as code. - **THEN** the body is preserved as non-expanding authored data - **THEN** the parser does not execute or reinterpret the receiving command +#### Scenario: Quoted delimiter adjacent to the operator +- **WHEN** Bash parses `cat <<'EOF'` followed by a body and the `EOF` delimiter +- **THEN** the quoted delimiter is recognized without requiring whitespace after `<<` +- **THEN** the result is not reported as missing a delimiter + #### Scenario: Executable substitution in an expanding body - **WHEN** a supported expanding heredoc body contains command substitution - **THEN** the inner command is exposed or the result is unparseable - **THEN** the body is not reported as completely static while executable content is hidden + +#### Scenario: Tab-stripping heredoc +- **WHEN** Bash parses `<<-EOF` with tab-indented body and delimiter lines +- **THEN** the redirect records that leading tabs are stripped +- **THEN** authored body provenance remains available + +### Requirement: Bash here strings are explicit data redirects +v0.3 SHALL parse Bash `<<< word` as a `HereString` redirect. Its operand SHALL +use the normal shell value domain, SHALL NOT be path-relevant, and SHALL account +for Bash's deterministic trailing newline when an exact effective data value is +reported. + +PowerShell here-strings SHALL remain quoted value tokens under the existing +PowerShell grammar and SHALL NOT be reported as redirect operations. + +#### Scenario: Literal Bash here string +- **WHEN** Bash parses `cat <<< "hello"` +- **THEN** the redirect operation is here string +- **THEN** its exact effective data is `hello` followed by one newline +- **THEN** the redirect is not path-relevant + +#### Scenario: Dynamic Bash here string +- **WHEN** Bash parses `cat <<< "$value"` +- **THEN** the redirect target remains unknown unless the value is proved +- **THEN** the parser does not execute or inspect the runtime variable + +#### Scenario: PowerShell here string remains a value +- **WHEN** PowerShell parses a literal here string passed to `Write-Output` +- **THEN** the here string remains an authored PowerShell argument +- **THEN** no `HereString` redirect operation is emitted diff --git a/openspec/changes/v0-3-structured-shell-analysis/specs/structured-shell-syntax/spec.md b/openspec/changes/v0-3-structured-shell-analysis/specs/structured-shell-syntax/spec.md index 3e49f81..4b76be9 100644 --- a/openspec/changes/v0-3-structured-shell-analysis/specs/structured-shell-syntax/spec.md +++ b/openspec/changes/v0-3-structured-shell-analysis/specs/structured-shell-syntax/spec.md @@ -5,6 +5,12 @@ Every fully parsed command SHALL expose one library-owned syntax root that preserves the authored nesting and source order of supported command lists, pipelines, groups, simple commands, loops, and branches. +The public syntax family SHALL be a closed hierarchy of records derived from +`ShellSyntaxNode`. Every node SHALL expose a `ShellSyntaxKind` discriminant and +zero SHALL mean `Unknown`. The locked family SHALL include block, simple +command, pipeline, command list, group, foreach, condition loop, conditional, +conditional branch, and command substitution nodes. + #### Scenario: Existing flat command receives a structural root - **WHEN** either parser parses `git status && dotnet test` - **THEN** the syntax root contains the two simple commands in authored order @@ -14,6 +20,11 @@ pipelines, groups, simple commands, loops, and branches. - **WHEN** a consumer references the public syntax-node base type - **THEN** it cannot derive and inject an external node implementation +#### Scenario: Later node kind is not silently authorized +- **WHEN** a later package returns a derived node or kind an older consumer does not recognize +- **THEN** a display visitor may show an unknown node +- **THEN** an authorization visitor fails closed + ### Requirement: Simple-command nodes preserve existing leaves A simple-command syntax node SHALL expose the existing `Clause` facts rather than replacing `VerbChain`, `Arg`, `Redirect`, or `ClauseElement` with a second @@ -51,6 +62,17 @@ without treating the script block as one opaque argument. - **THEN** the iterable preserves the two literal array elements - **THEN** the body contains one simple command for `Remove-Item` +### Requirement: Shared loop structure does not erase shell grammar +`ForEachSyntax` SHALL preserve the normalized binding name, the authored +binding source, the raw iterable source fragment, commands discovered in the +iterator, and the body. It SHALL NOT normalize Bash words and PowerShell +expressions into a false shared expression grammar. + +#### Scenario: Iterator with no exact outer span +- **WHEN** a loop is lifted from decoded wrapper content without an exact mapping to the outer source +- **THEN** the iterable raw text remains available +- **THEN** its outer source start and length are null + ### Requirement: Condition loops and branches preserve executable regions The parser SHALL preserve the condition, every branch body, and the continuation after each supported Bash or PowerShell condition loop or branch @@ -92,3 +114,16 @@ syntax SHALL be diagnostic evidence only. #### Scenario: Unknown PowerShell expression boundary - **WHEN** a PowerShell control-flow expression contains execution-bearing syntax the parser cannot delimit completely - **THEN** the result is unparseable even if the body commands were discovered + +#### Scenario: Deferred Bash process substitution +- **WHEN** Bash encounters `diff <(git show HEAD) <(git show HEAD~1)` in v0.3 +- **THEN** the whole result is unparseable until both commands and the produced descriptors are modeled + +#### Scenario: Deferred background execution +- **WHEN** Bash encounters a single-`&` background list in v0.3 +- **THEN** the whole result is unparseable until concurrency and state boundaries are specified + +#### Scenario: Ordinary PowerShell script-block argument +- **WHEN** PowerShell parses a script block as an ordinary command argument rather than a recognized statement body +- **THEN** it remains an opaque dynamic argument +- **THEN** the parser does not invent the block contents as commands that necessarily execute diff --git a/openspec/changes/v0-3-structured-shell-analysis/tasks.md b/openspec/changes/v0-3-structured-shell-analysis/tasks.md index 8160118..156bd15 100644 --- a/openspec/changes/v0-3-structured-shell-analysis/tasks.md +++ b/openspec/changes/v0-3-structured-shell-analysis/tasks.md @@ -1,13 +1,15 @@ ## 1. Contract Lock -- [ ] 1.1 Review Appendix A and lock the public syntax-root, node, occurrence, ancestry, value-domain, and redirect-detail type names and members. -- [ ] 1.2 Decide and document whether projections share identical `Clause` instances or guarantee value equality only. -- [ ] 1.3 Lock candidate-count, structural-nesting, and existing wrapper-recursion limits with boundary scenarios. -- [ ] 1.4 Review Appendix B, lock the v0.3 grammar separately for Bash and PowerShell, and park every deferred form explicitly. -- [ ] 1.5 Resolve the initial pattern and divergent-cwd exposure rules, updating the bounded-analysis specification. +- [x] 1.1 Review Appendix A and lock the public syntax-root, node, occurrence, ancestry, value-domain, and redirect-detail type names and members. +- [x] 1.2 Decide and document whether projections share identical `Clause` instances or guarantee value equality only. +- [x] 1.3 Lock candidate-count, structural-nesting, and existing wrapper-recursion limits with boundary scenarios. +- [x] 1.4 Review Appendix B, lock the v0.3 grammar separately for Bash and PowerShell, and park every deferred form explicitly. +- [x] 1.5 Resolve the initial pattern and divergent-cwd exposure rules, updating the bounded-analysis specification. - [ ] 1.6 Synchronize the accepted public API and shared requirements into `SPEC.md`. - [ ] 1.7 Synchronize PowerShell grammar and analysis deltas into `SPEC.POWERSHELL.md`. -- [ ] 1.8 Update `PROJECT_CONTEXT.md` and `IMPLEMENTATION_PLAN.md` with the accepted v0.3 scope and delivery slices. +- [x] 1.8 Update `PROJECT_CONTEXT.md` and `IMPLEMENTATION_PLAN.md` with the accepted v0.3 scope and delivery slices. +- [x] 1.9 Add a paired Bash and PowerShell design corpus that records current behavior, desired structure, command occurrences, bounded values, redirect facts, compatibility projections, and security invariants. +- [ ] 1.10 Promote each design case into the executable corpus as its production parser slice lands. ## 2. Behavior-Preserving Shared Preparation @@ -82,20 +84,24 @@ - [ ] 9.1 Add Bash `while` and `until` with condition and body command occurrences. - [ ] 9.2 Add Bash `if` / `elif` / `else` with conservative branch-state joins. -- [ ] 9.3 Add Bash `case` only after pattern and branch-selection uncertainty is specified. -- [ ] 9.4 Add PowerShell `while` and `do` forms with separately specified expression boundaries. +- [ ] 9.3 Defer Bash `case` until after stable v0.3 and add it only after pattern and branch-selection uncertainty is specified. +- [ ] 9.4 Add PowerShell `while` with the locked condition-pipeline boundary; defer `do` forms until after stable v0.3. - [ ] 9.5 Add PowerShell `if` / `elseif` / `else` with conservative branch-state joins. -- [ ] 9.6 Add PowerShell `switch` only after string, regex, wildcard, and script-block modes are bounded explicitly. +- [ ] 9.6 Defer PowerShell `switch` until after stable v0.3 and add it only after string, regex, wildcard, and script-block modes are bounded explicitly. - [ ] 9.7 Add paired security scenarios proving every condition and branch command remains visible. -## 10. Separately Gated Syntax Concerns - -- [ ] 10.1 Specify heredoc delimiter quoting, expansion mode, body provenance, substitutions, and completeness before enabling heredoc bodies. -- [ ] 10.2 Specify process-substitution command discovery and the unknown produced descriptor/path value before enabling it. -- [ ] 10.3 Specify background-list concurrency, ordering, and shell-state boundaries before enabling single `&`. -- [ ] 10.4 Specify C-style loop and arithmetic hidden-execution behavior before enabling either construct. -- [ ] 10.5 Keep URL-versus-glob and environment-assignment approval behavior in executable-aware consumer issues unless a shell lexical fact is missing. -- [ ] 10.6 Reproduce multiline quoted-argument reports against exact parser input before assigning a parser change. +## 10. Heredoc / Here-String Slice and Separately Gated Follow-ups + +- [x] 10.1 Specify heredoc delimiter adjacency and quoting, expansion mode, body provenance, substitutions, tab stripping, completeness, and Bash here-string semantics. +- [ ] 10.2 Preserve existing `<<` / `<<-` behavior and fix quoted-delimiter adjacency without regressing the v0.2 compatibility redirect. +- [ ] 10.3 Add explicit heredoc delimiter/body/expansion/completeness facts and surface every supported substitution command. +- [ ] 10.4 Add Bash `<<<` here-string tokenization, explicit redirect facts, bounded operand analysis, and trailing-newline semantics. +- [ ] 10.5 Add direct, malformed, quoted/unquoted, tab-stripped, dynamic, and substitution-bearing corpus cases plus real-Bash parse-only validation. +- [ ] 10.6 After stable v0.3, specify process-substitution command discovery and the unknown produced descriptor/path value before enabling it. +- [ ] 10.7 After stable v0.3, specify background-list concurrency, ordering, and shell-state boundaries before enabling single `&`. +- [ ] 10.8 Specify C-style loop and arithmetic hidden-execution behavior before enabling either construct. +- [ ] 10.9 Keep URL-versus-glob and environment-assignment approval behavior in executable-aware consumer issues unless a shell lexical fact is missing. +- [ ] 10.10 Reproduce multiline quoted-argument reports against exact parser input before assigning a parser change. ## 11. Verification and Release diff --git a/tests/ShellSyntaxTree.Tests/Corpus/PiiAuditTests.cs b/tests/ShellSyntaxTree.Tests/Corpus/PiiAuditTests.cs index 5648177..9225f14 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/PiiAuditTests.cs +++ b/tests/ShellSyntaxTree.Tests/Corpus/PiiAuditTests.cs @@ -16,10 +16,9 @@ namespace ShellSyntaxTree.Tests.Corpus; /// /// PII audit gate per SPEC §14 / SPEC.POWERSHELL.md §14. Scans every JSON -/// corpus entry under every tests/ShellSyntaxTree.Tests/Corpus/<shell>/ -/// directory for the forbidden patterns listed in the sanitization table. -/// The audit reads from the build-output copy of the corpus (the same -/// location the runner pulls from) so CI runs against the same bytes a +/// entry under the executable corpus and the pre-implementation design corpus +/// for the forbidden patterns listed in the sanitization table. The audit +/// reads from the build-output copies so CI runs against the same bytes a /// developer's local dotnet test would. /// /// @@ -101,35 +100,29 @@ public class PiiAuditTests [Fact] public void Corpus_contains_no_pii_per_spec_section_14() { - var root = Path.Combine(AppContext.BaseDirectory, "Corpus"); - if (!Directory.Exists(root)) - { - // The audit is vacuous when there's no corpus to audit; the - // separate CorpusRunnerTests asserts the corpus is present. - return; - } - var hits = new List(); - foreach (var shellDir in Directory.GetDirectories(root).OrderBy(d => d)) + var roots = new[] { "Corpus", "DesignCorpus" }; + foreach (var relativeRoot in roots) { - var shell = Path.GetFileName(shellDir); - foreach (var file in Directory.GetFiles(shellDir, "*.json").OrderBy(f => f)) + var root = Path.Combine(AppContext.BaseDirectory, relativeRoot); + if (!Directory.Exists(root)) + { + continue; + } + + foreach (var file in Directory.GetFiles( + root, "*.json", SearchOption.AllDirectories).OrderBy(f => f)) { - var name = $"{shell}/{Path.GetFileName(file)}"; - JsonDocument doc; + var name = Path.GetRelativePath(AppContext.BaseDirectory, file) + .Replace('\\', '/'); try { - doc = JsonDocument.Parse(File.ReadAllText(file)); + using var doc = JsonDocument.Parse(File.ReadAllText(file)); + Walk(doc.RootElement, name, fieldPath: string.Empty, hits); } catch (JsonException ex) { hits.Add($"{name}: failed to parse JSON for PII audit: {ex.Message}"); - continue; - } - - using (doc) - { - Walk(doc.RootElement, name, fieldPath: string.Empty, hits); } } } diff --git a/tests/ShellSyntaxTree.Tests/DesignCorpus/README.md b/tests/ShellSyntaxTree.Tests/DesignCorpus/README.md new file mode 100644 index 0000000..3350805 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/DesignCorpus/README.md @@ -0,0 +1,38 @@ +# v0.3 Design Corpus + +This directory is a pre-implementation contract corpus for the +`v0-3-structured-shell-analysis` OpenSpec change. It does not feed the shipped +v0.2 AST runner because that schema cannot represent nested syntax, command +occurrences, effective values, or joined state. + +Each case records these independent views: + +1. `current`: the behavior the released flat parser produces today; +2. `syntax`: the desired authored structural nodes and their parent slots; +3. `commands`: every authored command that may execute, exactly once; +4. `effectiveValues`: bounded shell facts without executable policy claims; +5. `securityInvariants`: properties every future implementation and consumer + must preserve. + +The accompanying tests deserialize with unknown-member rejection, validate +node and command references, compare `current` with the real v0.2 parser, and +include these JSON files in the repository PII audit. A case moves into +`Corpus//` only after the corresponding public API and parser behavior +exist and its full expected AST can pass the normal corpus runner. + +The design corpus is intentionally shell-specific. Similar Bash and PowerShell +cases may share structural expectations while retaining different quoting, +expression, option-binding, object, and scope semantics. + +Each command records one `immediateRole` plus its full `ancestry`. These are +deliberately separate: a pipeline stage nested inside a loop body is immediately +a pipeline stage and still carries the enclosing loop-body context. Likewise, +`isComplete` describes command discovery and structure, not value precision; a +complete occurrence may contain an `Unknown` effective value. + +The v0.3 contract fixes the candidate cap at 32. Each shell has a case at the +cap and a 33-value overflow case that collapses to `Unknown` rather than +publishing a truncated finite set. Supported heredocs and Bash here strings +record data separately from executable substitutions and path-relevant +redirects. Constructs deliberately deferred beyond stable v0.3 remain in the +corpus as unparseable security boundaries. diff --git a/tests/ShellSyntaxTree.Tests/DesignCorpus/V03DesignCorpusTests.cs b/tests/ShellSyntaxTree.Tests/DesignCorpus/V03DesignCorpusTests.cs new file mode 100644 index 0000000..fbf114f --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/DesignCorpus/V03DesignCorpusTests.cs @@ -0,0 +1,456 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Aaron Stannard +// +// ----------------------------------------------------------------------- +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +using Xunit; + +namespace ShellSyntaxTree.Tests.DesignCorpus; + +public class V03DesignCorpusTests +{ + private const int MaxValueCandidates = 32; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, + Converters = { new JsonStringEnumConverter() }, + }; + + [Fact] + public void Design_corpus_is_well_formed_and_balanced_across_shells() + { + var files = LoadFiles(); + Assert.Equal(new[] { DesignShell.Bash, DesignShell.PowerShell }, + files.Select(file => file.Shell).OrderBy(shell => shell).ToArray()); + + var ids = new HashSet(StringComparer.Ordinal); + foreach (var file in files) + { + Assert.True(file.Cases.Count >= 10, + $"{file.Shell} design corpus has only {file.Cases.Count} cases; expected at least 10."); + + foreach (var designCase in file.Cases) + { + Assert.False(string.IsNullOrWhiteSpace(designCase.Id)); + Assert.True(ids.Add(designCase.Id), $"Duplicate design-case id '{designCase.Id}'."); + Assert.False(string.IsNullOrWhiteSpace(designCase.Concern)); + Assert.False(string.IsNullOrWhiteSpace(designCase.Input)); + ValidateDesiredShape(file.Shell, designCase); + } + + var values = file.Cases + .SelectMany(designCase => designCase.Desired.Commands) + .SelectMany(command => command.EffectiveValues) + .ToArray(); + Assert.True(values.Any(value => + value.Kind == DesignValueKind.FiniteSet + && value.Values.Count == MaxValueCandidates), + $"{file.Shell}: no case exercises the finite candidate cap."); + } + } + + [Fact] + public void Current_expectations_match_the_v0_2_parsers() + { + foreach (var file in LoadFiles()) + { + var parser = CreateParser(file.Shell); + foreach (var designCase in file.Cases) + { + var actual = parser.Parse(designCase.Input); + Assert.True( + actual.IsUnparseable == designCase.Current.IsUnparseable, + $"{designCase.Id}: current IsUnparseable expected " + + $"{designCase.Current.IsUnparseable}, actual {actual.IsUnparseable}. " + + $"Reason: {actual.UnparseableReason}"); + + if (designCase.Current.ReasonContains is not null) + { + Assert.Contains( + designCase.Current.ReasonContains, + actual.UnparseableReason ?? string.Empty, + StringComparison.OrdinalIgnoreCase); + } + } + } + } + + private static void ValidateDesiredShape(DesignShell shell, V03DesignCase designCase) + { + var desired = designCase.Desired; + Assert.NotEmpty(desired.Syntax); + Assert.NotEmpty(desired.SecurityInvariants); + + var nodes = desired.Syntax.ToDictionary(node => node.Id, StringComparer.Ordinal); + Assert.Single(desired.Syntax, node => node.Parent is null); + + foreach (var node in desired.Syntax) + { + Assert.False(string.IsNullOrWhiteSpace(node.Id), $"{designCase.Id}: syntax node has no id."); + if (node.Parent is not null) + { + Assert.True(nodes.ContainsKey(node.Parent), + $"{designCase.Id}: node '{node.Id}' references unknown parent '{node.Parent}'."); + } + + if (node.CommandIndex is not null) + { + Assert.Equal(DesignSyntaxKind.SimpleCommand, node.Kind); + Assert.InRange(node.CommandIndex.Value, 0, desired.Commands.Count - 1); + } + } + + if (desired.IsUnparseable) + { + Assert.Contains(SecurityInvariant.PartialTreeDiagnosticOnly, desired.SecurityInvariants); + Assert.Empty(desired.Commands); + Assert.Empty(desired.Compatibility.Verbs); + } + else + { + Assert.NotEmpty(desired.Commands); + Assert.Equal( + Enumerable.Range(0, desired.Commands.Count), + desired.Syntax + .Where(node => node.CommandIndex is not null) + .Select(node => node.CommandIndex!.Value) + .OrderBy(index => index)); + } + + foreach (var command in desired.Commands) + { + Assert.False(string.IsNullOrWhiteSpace(command.AuthoredVerb)); + Assert.NotNull(command.ImmediateRole); + Assert.NotEmpty(command.Ancestry); + foreach (var ancestor in command.Ancestry) + { + Assert.True(nodes.ContainsKey(ancestor), + $"{designCase.Id}: command '{command.AuthoredVerb}' references unknown ancestor '{ancestor}'."); + } + + foreach (var value in command.EffectiveValues) + { + ValidateValue(shell, designCase.Id, value); + } + + foreach (var redirect in command.Redirects) + { + Assert.True(redirect.IsPathRelevant == (redirect.Operation is + DesignRedirectOperation.FileInput + or DesignRedirectOperation.FileOutput + or DesignRedirectOperation.FileAppend + or DesignRedirectOperation.CombinedOutput + or DesignRedirectOperation.CombinedOutputAppend)); + + if (redirect.Target is not null) + { + ValidateValue(shell, designCase.Id, redirect.Target); + } + + Assert.Equal( + redirect.Operation == DesignRedirectOperation.HereDocument, + redirect.HereDocument is not null); + + if (redirect.HereDocument is not null) + { + Assert.False(string.IsNullOrWhiteSpace(redirect.HereDocument.DelimiterRaw)); + Assert.NotEqual( + DesignHereDocumentExpansionMode.Unknown, + redirect.HereDocument.ExpansionMode); + } + + if (redirect.Operation == DesignRedirectOperation.HereString) + { + Assert.NotNull(redirect.Target); + } + } + } + + Assert.Equal( + desired.Commands.Select(command => command.AuthoredVerb), + desired.Compatibility.Verbs); + } + + private static void ValidateValue( + DesignShell shell, string caseId, DesignValueExpectation value) + { + Assert.False(string.IsNullOrWhiteSpace(value.SourceElement)); + switch (value.Kind) + { + case DesignValueKind.Exact: + Assert.Single(value.Values); + Assert.Null(value.Pattern); + break; + case DesignValueKind.FiniteSet: + Assert.InRange(value.Values.Count, 2, MaxValueCandidates); + Assert.Equal(value.Values.Count, value.Values.Distinct(StringComparer.Ordinal).Count()); + Assert.Null(value.Pattern); + break; + case DesignValueKind.Pattern: + Assert.Empty(value.Values); + Assert.False(string.IsNullOrWhiteSpace(value.Pattern)); + Assert.False(string.IsNullOrWhiteSpace(value.CoveringDirectory)); + break; + case DesignValueKind.Unknown: + Assert.Empty(value.Values); + Assert.Null(value.Pattern); + Assert.Null(value.CoveringDirectory); + break; + default: + throw new InvalidOperationException( + $"{caseId}: unsupported value kind '{value.Kind}' for {shell}."); + } + } + + private static IShellParser CreateParser(DesignShell shell) => shell switch + { + DesignShell.Bash => new BashParser(new BashParserOptions + { + HomeDirectory = "/home/test", + WorkingDirectory = "/work", + }), + DesignShell.PowerShell => new PwshParser(new PwshParserOptions + { + HomeDirectory = "C:/Users/user", + WorkingDirectory = "C:/work", + }), + _ => throw new InvalidOperationException($"Unsupported design-corpus shell '{shell}'."), + }; + + private static IReadOnlyList LoadFiles() + { + var root = Path.Combine(AppContext.BaseDirectory, "DesignCorpus", "v0.3"); + Assert.True(Directory.Exists(root), $"Design corpus directory not found: {root}"); + + return Directory.GetFiles(root, "*.json") + .OrderBy(path => path, StringComparer.Ordinal) + .Select(path => JsonSerializer.Deserialize( + File.ReadAllText(path), JsonOptions) + ?? throw new InvalidOperationException($"Design corpus file deserialized to null: {path}")) + .ToArray(); + } +} + +public sealed record V03DesignCorpusFile +{ + public DesignShell Shell { get; init; } + + public IReadOnlyList Cases { get; init; } = []; +} + +public sealed record V03DesignCase +{ + public string Id { get; init; } = ""; + + public string Concern { get; init; } = ""; + + public string Input { get; init; } = ""; + + public CurrentBehaviorExpectation Current { get; init; } = new(); + + public DesiredDesignExpectation Desired { get; init; } = new(); + + public string? Notes { get; init; } +} + +public sealed record CurrentBehaviorExpectation +{ + public bool IsUnparseable { get; init; } + + public string? ReasonContains { get; init; } +} + +public sealed record DesiredDesignExpectation +{ + public bool IsUnparseable { get; init; } + + public IReadOnlyList Syntax { get; init; } = []; + + public IReadOnlyList Commands { get; init; } = []; + + public CompatibilityExpectation Compatibility { get; init; } = new(); + + public IReadOnlyList SecurityInvariants { get; init; } = []; +} + +public sealed record DesignSyntaxExpectation +{ + public string Id { get; init; } = ""; + + public DesignSyntaxKind Kind { get; init; } + + public string? Parent { get; init; } + + public DesignSyntaxSlot Slot { get; init; } + + public string? Binding { get; init; } + + public int? CommandIndex { get; init; } +} + +public sealed record DesignCommandExpectation +{ + public string AuthoredVerb { get; init; } = ""; + + public string? CanonicalVerb { get; init; } + + public DesignCommandRole? ImmediateRole { get; init; } + + public IReadOnlyList Ancestry { get; init; } = []; + + public bool IsComplete { get; init; } + + public IReadOnlyList EffectiveValues { get; init; } = []; + + public IReadOnlyList Redirects { get; init; } = []; +} + +public sealed record DesignValueExpectation +{ + public string SourceElement { get; init; } = ""; + + public DesignValueKind Kind { get; init; } + + public IReadOnlyList Values { get; init; } = []; + + public string? Pattern { get; init; } + + public string? CoveringDirectory { get; init; } + + public bool IsPolicySensitive { get; init; } +} + +public sealed record CompatibilityExpectation +{ + public IReadOnlyList Verbs { get; init; } = []; + + public bool PreservesAuthoredDynamicValues { get; init; } +} + +public sealed record DesignRedirectExpectation +{ + public DesignRedirectOperation Operation { get; init; } + + public int? SourceDescriptor { get; init; } + + public int? TargetDescriptor { get; init; } + + public DesignValueExpectation? Target { get; init; } + + public DesignHereDocumentExpectation? HereDocument { get; init; } + + public bool IsPathRelevant { get; init; } + + public bool IsComplete { get; init; } +} + +public sealed record DesignHereDocumentExpectation +{ + public string DelimiterRaw { get; init; } = ""; + + public string BodyRaw { get; init; } = ""; + + public DesignHereDocumentExpansionMode ExpansionMode { get; init; } + + public bool StripLeadingTabs { get; init; } + + public bool IsComplete { get; init; } +} + +public enum DesignHereDocumentExpansionMode +{ + Unknown, + Literal, + Expand, +} + +public enum DesignShell +{ + Bash, + PowerShell, +} + +public enum DesignSyntaxKind +{ + Block, + CommandList, + Pipeline, + ForEach, + ConditionLoop, + Conditional, + SimpleCommand, + OpaqueArgument, + Unsupported, +} + +public enum DesignSyntaxSlot +{ + Root, + Statement, + Iterator, + Condition, + Body, + Then, + Else, + Stage, + Argument, +} + +public enum DesignCommandRole +{ + Ordinary, + PipelineStage, + Condition, + Iterator, + LoopBody, + Branch, + Substitution, +} + +public enum DesignValueKind +{ + Unknown, + Exact, + FiniteSet, + Pattern, +} + +public enum DesignRedirectOperation +{ + Unknown, + FileInput, + FileOutput, + FileAppend, + DescriptorDuplicate, + DescriptorClose, + DescriptorMove, + CombinedOutput, + CombinedOutputAppend, + HereDocument, + HereString, +} + +public enum SecurityInvariant +{ + AllCommandsVisible, + IteratorCommandsVisible, + ConditionCommandsVisible, + UnknownPolicyValueFailsClosed, + EveryFiniteCandidateEvaluated, + NoFilesystemEnumeration, + StateJoinConservative, + NoSyntheticOperator, + PartialTreeDiagnosticOnly, + OpaqueDataNotExecuted, + ContextualKeywordNotControlFlow, + ShellSpecificOptionSemantics, + StaticRedirectNotDynamic, +} diff --git a/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/bash.json b/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/bash.json new file mode 100644 index 0000000..abef8e7 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/bash.json @@ -0,0 +1,519 @@ +{ + "shell": "Bash", + "cases": [ + { + "id": "bash-for-literal-finite", + "concern": "Finite literal loop binding", + "input": "for f in a.txt b.txt; do rm -- \"$f\"; done", + "current": { "isUnparseable": true, "reasonContains": "'for'" }, + "desired": { + "syntax": [ + { "id": "root", "kind": "Block", "slot": "Root" }, + { "id": "loop", "kind": "ForEach", "parent": "root", "slot": "Statement", "binding": "f" }, + { "id": "body", "kind": "Block", "parent": "loop", "slot": "Body" }, + { "id": "rm", "kind": "SimpleCommand", "parent": "body", "slot": "Statement", "commandIndex": 0 } + ], + "commands": [ + { + "authoredVerb": "rm", + "immediateRole": "LoopBody", + "ancestry": ["root", "loop", "body"], + "isComplete": true, + "effectiveValues": [ + { "sourceElement": "\"$f\"", "kind": "FiniteSet", "values": ["a.txt", "b.txt"], "isPolicySensitive": true } + ] + } + ], + "compatibility": { "verbs": ["rm"], "preservesAuthoredDynamicValues": true }, + "securityInvariants": ["AllCommandsVisible", "EveryFiniteCandidateEvaluated", "ShellSpecificOptionSemantics", "NoSyntheticOperator"] + } + }, + { + "id": "bash-for-static-glob", + "concern": "Bounded glob without filesystem enumeration", + "input": "for f in /tmp/*.txt; do rm -- \"$f\"; done", + "current": { "isUnparseable": true, "reasonContains": "'for'" }, + "desired": { + "syntax": [ + { "id": "root", "kind": "Block", "slot": "Root" }, + { "id": "loop", "kind": "ForEach", "parent": "root", "slot": "Statement", "binding": "f" }, + { "id": "body", "kind": "Block", "parent": "loop", "slot": "Body" }, + { "id": "rm", "kind": "SimpleCommand", "parent": "body", "slot": "Statement", "commandIndex": 0 } + ], + "commands": [ + { + "authoredVerb": "rm", + "immediateRole": "LoopBody", + "ancestry": ["root", "loop", "body"], + "isComplete": true, + "effectiveValues": [ + { "sourceElement": "\"$f\"", "kind": "Pattern", "pattern": "/tmp/*.txt", "coveringDirectory": "/tmp", "isPolicySensitive": true } + ] + } + ], + "compatibility": { "verbs": ["rm"], "preservesAuthoredDynamicValues": true }, + "securityInvariants": ["AllCommandsVisible", "NoFilesystemEnumeration", "UnknownPolicyValueFailsClosed"] + } + }, + { + "id": "bash-for-dynamic-glob-root", + "concern": "Dynamic glob covering directory", + "input": "for f in \"$DIR\"/*.txt; do rm -- \"$f\"; done", + "current": { "isUnparseable": true, "reasonContains": "'for'" }, + "desired": { + "syntax": [ + { "id": "root", "kind": "Block", "slot": "Root" }, + { "id": "loop", "kind": "ForEach", "parent": "root", "slot": "Statement", "binding": "f" }, + { "id": "body", "kind": "Block", "parent": "loop", "slot": "Body" }, + { "id": "rm", "kind": "SimpleCommand", "parent": "body", "slot": "Statement", "commandIndex": 0 } + ], + "commands": [ + { + "authoredVerb": "rm", + "immediateRole": "LoopBody", + "ancestry": ["root", "loop", "body"], + "isComplete": true, + "effectiveValues": [ + { "sourceElement": "\"$f\"", "kind": "Unknown", "isPolicySensitive": true } + ] + } + ], + "compatibility": { "verbs": ["rm"], "preservesAuthoredDynamicValues": true }, + "securityInvariants": ["AllCommandsVisible", "UnknownPolicyValueFailsClosed", "NoFilesystemEnumeration"] + } + }, + { + "id": "bash-for-command-substitution-iterator", + "concern": "Executable iterator with unknown produced values", + "input": "for f in $(find /tmp -type f); do rm -- \"$f\"; done", + "current": { "isUnparseable": true, "reasonContains": "'for'" }, + "desired": { + "syntax": [ + { "id": "root", "kind": "Block", "slot": "Root" }, + { "id": "loop", "kind": "ForEach", "parent": "root", "slot": "Statement", "binding": "f" }, + { "id": "iterator", "kind": "Block", "parent": "loop", "slot": "Iterator" }, + { "id": "find", "kind": "SimpleCommand", "parent": "iterator", "slot": "Statement", "commandIndex": 0 }, + { "id": "body", "kind": "Block", "parent": "loop", "slot": "Body" }, + { "id": "rm", "kind": "SimpleCommand", "parent": "body", "slot": "Statement", "commandIndex": 1 } + ], + "commands": [ + { "authoredVerb": "find", "immediateRole": "Iterator", "ancestry": ["root", "loop", "iterator"], "isComplete": true }, + { + "authoredVerb": "rm", + "immediateRole": "LoopBody", + "ancestry": ["root", "loop", "body"], + "isComplete": true, + "effectiveValues": [ + { "sourceElement": "\"$f\"", "kind": "Unknown", "isPolicySensitive": true } + ] + } + ], + "compatibility": { "verbs": ["find", "rm"], "preservesAuthoredDynamicValues": true }, + "securityInvariants": ["AllCommandsVisible", "IteratorCommandsVisible", "UnknownPolicyValueFailsClosed"] + } + }, + { + "id": "bash-for-native-option-injection", + "concern": "Finite value can change native option parsing", + "input": "for f in -rf /tmp/x; do rm \"$f\"; done", + "current": { "isUnparseable": true, "reasonContains": "'for'" }, + "desired": { + "syntax": [ + { "id": "root", "kind": "Block", "slot": "Root" }, + { "id": "loop", "kind": "ForEach", "parent": "root", "slot": "Statement", "binding": "f" }, + { "id": "body", "kind": "Block", "parent": "loop", "slot": "Body" }, + { "id": "rm", "kind": "SimpleCommand", "parent": "body", "slot": "Statement", "commandIndex": 0 } + ], + "commands": [ + { + "authoredVerb": "rm", + "immediateRole": "LoopBody", + "ancestry": ["root", "loop", "body"], + "isComplete": true, + "effectiveValues": [ + { "sourceElement": "\"$f\"", "kind": "FiniteSet", "values": ["-rf", "/tmp/x"], "isPolicySensitive": true } + ] + } + ], + "compatibility": { "verbs": ["rm"], "preservesAuthoredDynamicValues": true }, + "securityInvariants": ["EveryFiniteCandidateEvaluated", "ShellSpecificOptionSemantics", "UnknownPolicyValueFailsClosed"] + } + }, + { + "id": "bash-nested-for-cross-product", + "concern": "Bounded nested-loop value combination", + "input": "for d in a b; do for f in x y; do echo \"$d/$f\"; done; done", + "current": { "isUnparseable": true, "reasonContains": "'for'" }, + "desired": { + "syntax": [ + { "id": "root", "kind": "Block", "slot": "Root" }, + { "id": "outer", "kind": "ForEach", "parent": "root", "slot": "Statement", "binding": "d" }, + { "id": "outerBody", "kind": "Block", "parent": "outer", "slot": "Body" }, + { "id": "inner", "kind": "ForEach", "parent": "outerBody", "slot": "Statement", "binding": "f" }, + { "id": "innerBody", "kind": "Block", "parent": "inner", "slot": "Body" }, + { "id": "echo", "kind": "SimpleCommand", "parent": "innerBody", "slot": "Statement", "commandIndex": 0 } + ], + "commands": [ + { + "authoredVerb": "echo", + "immediateRole": "LoopBody", + "ancestry": ["root", "outer", "outerBody", "inner", "innerBody"], + "isComplete": true, + "effectiveValues": [ + { "sourceElement": "\"$d/$f\"", "kind": "FiniteSet", "values": ["a/x", "a/y", "b/x", "b/y"], "isPolicySensitive": false } + ] + } + ], + "compatibility": { "verbs": ["echo"], "preservesAuthoredDynamicValues": true }, + "securityInvariants": ["AllCommandsVisible", "EveryFiniteCandidateEvaluated"] + } + }, + { + "id": "bash-for-body-pipeline", + "concern": "Command roles compose with loop and pipeline ancestry", + "input": "for f in a b; do printf '%s\\n' \"$f\" | sort; done", + "current": { "isUnparseable": true, "reasonContains": "'for'" }, + "desired": { + "syntax": [ + { "id": "root", "kind": "Block", "slot": "Root" }, + { "id": "loop", "kind": "ForEach", "parent": "root", "slot": "Statement", "binding": "f" }, + { "id": "body", "kind": "Block", "parent": "loop", "slot": "Body" }, + { "id": "pipe", "kind": "Pipeline", "parent": "body", "slot": "Statement" }, + { "id": "printf", "kind": "SimpleCommand", "parent": "pipe", "slot": "Stage", "commandIndex": 0 }, + { "id": "sort", "kind": "SimpleCommand", "parent": "pipe", "slot": "Stage", "commandIndex": 1 } + ], + "commands": [ + { + "authoredVerb": "printf", + "immediateRole": "PipelineStage", + "ancestry": ["root", "loop", "body", "pipe"], + "isComplete": true, + "effectiveValues": [ + { "sourceElement": "\"$f\"", "kind": "FiniteSet", "values": ["a", "b"], "isPolicySensitive": false } + ] + }, + { "authoredVerb": "sort", "immediateRole": "PipelineStage", "ancestry": ["root", "loop", "body", "pipe"], "isComplete": true } + ], + "compatibility": { "verbs": ["printf", "sort"], "preservesAuthoredDynamicValues": true }, + "securityInvariants": ["AllCommandsVisible", "EveryFiniteCandidateEvaluated", "NoSyntheticOperator"] + }, + "notes": "Ancestry carries loop-body context while the immediate role is PipelineStage." + }, + { + "id": "bash-malformed-for-missing-done", + "concern": "Incomplete delimiter safe-fail", + "input": "for f in a b; do rm -- \"$f\"", + "current": { "isUnparseable": true, "reasonContains": "'for'" }, + "desired": { + "isUnparseable": true, + "syntax": [ + { "id": "root", "kind": "Unsupported", "slot": "Root" } + ], + "securityInvariants": ["PartialTreeDiagnosticOnly", "UnknownPolicyValueFailsClosed"] + } + }, + { + "id": "bash-contextual-for-argument", + "concern": "Control keyword outside command position", + "input": "echo for", + "current": { "isUnparseable": false }, + "desired": { + "syntax": [ + { "id": "root", "kind": "Block", "slot": "Root" }, + { "id": "echo", "kind": "SimpleCommand", "parent": "root", "slot": "Statement", "commandIndex": 0 } + ], + "commands": [ + { "authoredVerb": "echo", "immediateRole": "Ordinary", "ancestry": ["root"], "isComplete": true } + ], + "compatibility": { "verbs": ["echo"], "preservesAuthoredDynamicValues": false }, + "securityInvariants": ["AllCommandsVisible", "ContextualKeywordNotControlFlow"] + } + }, + { + "id": "bash-while-command-condition", + "concern": "Executable condition and loop body", + "input": "while curl https://example.invalid/ready; do rm /tmp/marker; done", + "current": { "isUnparseable": true, "reasonContains": "'while'" }, + "desired": { + "syntax": [ + { "id": "root", "kind": "Block", "slot": "Root" }, + { "id": "loop", "kind": "ConditionLoop", "parent": "root", "slot": "Statement" }, + { "id": "condition", "kind": "Block", "parent": "loop", "slot": "Condition" }, + { "id": "curl", "kind": "SimpleCommand", "parent": "condition", "slot": "Statement", "commandIndex": 0 }, + { "id": "body", "kind": "Block", "parent": "loop", "slot": "Body" }, + { "id": "rm", "kind": "SimpleCommand", "parent": "body", "slot": "Statement", "commandIndex": 1 } + ], + "commands": [ + { "authoredVerb": "curl", "immediateRole": "Condition", "ancestry": ["root", "loop", "condition"], "isComplete": true }, + { "authoredVerb": "rm", "immediateRole": "LoopBody", "ancestry": ["root", "loop", "body"], "isComplete": true } + ], + "compatibility": { "verbs": ["curl", "rm"], "preservesAuthoredDynamicValues": false }, + "securityInvariants": ["AllCommandsVisible", "ConditionCommandsVisible"] + } + }, + { + "id": "bash-if-branch-cwd-join", + "concern": "Divergent branch working directory", + "input": "if test -f marker; then cd /a; else cd /b; fi; cat file.txt", + "current": { "isUnparseable": true, "reasonContains": "'if'" }, + "desired": { + "syntax": [ + { "id": "root", "kind": "Block", "slot": "Root" }, + { "id": "if", "kind": "Conditional", "parent": "root", "slot": "Statement" }, + { "id": "condition", "kind": "Block", "parent": "if", "slot": "Condition" }, + { "id": "test", "kind": "SimpleCommand", "parent": "condition", "slot": "Statement", "commandIndex": 0 }, + { "id": "then", "kind": "Block", "parent": "if", "slot": "Then" }, + { "id": "cdA", "kind": "SimpleCommand", "parent": "then", "slot": "Statement", "commandIndex": 1 }, + { "id": "else", "kind": "Block", "parent": "if", "slot": "Else" }, + { "id": "cdB", "kind": "SimpleCommand", "parent": "else", "slot": "Statement", "commandIndex": 2 }, + { "id": "cat", "kind": "SimpleCommand", "parent": "root", "slot": "Statement", "commandIndex": 3 } + ], + "commands": [ + { "authoredVerb": "test", "immediateRole": "Condition", "ancestry": ["root", "if", "condition"], "isComplete": true }, + { "authoredVerb": "cd", "immediateRole": "Branch", "ancestry": ["root", "if", "then"], "isComplete": true }, + { "authoredVerb": "cd", "immediateRole": "Branch", "ancestry": ["root", "if", "else"], "isComplete": true }, + { + "authoredVerb": "cat", + "immediateRole": "Ordinary", + "ancestry": ["root"], + "isComplete": true, + "effectiveValues": [ + { "sourceElement": "file.txt", "kind": "Unknown", "isPolicySensitive": true } + ] + } + ], + "compatibility": { "verbs": ["test", "cd", "cd", "cat"], "preservesAuthoredDynamicValues": true }, + "securityInvariants": ["AllCommandsVisible", "ConditionCommandsVisible", "StateJoinConservative", "UnknownPolicyValueFailsClosed"] + } + }, + { + "id": "bash-process-substitution-gated", + "concern": "Unsupported hidden executable regions", + "input": "diff <(git show HEAD) <(git show HEAD~1)", + "current": { "isUnparseable": true, "reasonContains": "process substitution" }, + "desired": { + "isUnparseable": true, + "syntax": [ + { "id": "root", "kind": "Unsupported", "slot": "Root" } + ], + "securityInvariants": ["PartialTreeDiagnosticOnly", "UnknownPolicyValueFailsClosed"] + }, + "notes": "Remain unparseable until both inner git commands can be surfaced and the produced descriptor value is modeled conservatively." + }, + { + "id": "bash-static-fd-duplication", + "concern": "Static descriptor operation is not dynamic input", + "input": "dotnet test 2>&1", + "current": { "isUnparseable": false }, + "desired": { + "syntax": [ + { "id": "root", "kind": "Block", "slot": "Root" }, + { "id": "dotnet", "kind": "SimpleCommand", "parent": "root", "slot": "Statement", "commandIndex": 0 } + ], + "commands": [ + { + "authoredVerb": "dotnet", + "immediateRole": "Ordinary", + "ancestry": ["root"], + "isComplete": true, + "redirects": [ + { "operation": "DescriptorDuplicate", "sourceDescriptor": 2, "targetDescriptor": 1, "isPathRelevant": false, "isComplete": true } + ] + } + ], + "compatibility": { "verbs": ["dotnet"], "preservesAuthoredDynamicValues": true }, + "securityInvariants": ["AllCommandsVisible", "StaticRedirectNotDynamic"] + } + }, + { + "id": "bash-dynamic-fd-target", + "concern": "Computed descriptor target remains unknown", + "input": "command 2>&$FD", + "current": { "isUnparseable": false }, + "desired": { + "syntax": [ + { "id": "root", "kind": "Block", "slot": "Root" }, + { "id": "command", "kind": "SimpleCommand", "parent": "root", "slot": "Statement", "commandIndex": 0 } + ], + "commands": [ + { + "authoredVerb": "command", + "immediateRole": "Ordinary", + "ancestry": ["root"], + "isComplete": true, + "redirects": [ + { + "operation": "DescriptorDuplicate", + "sourceDescriptor": 2, + "target": { "sourceElement": "&$FD", "kind": "Unknown", "isPolicySensitive": true }, + "isPathRelevant": false, + "isComplete": false + } + ] + } + ], + "compatibility": { "verbs": ["command"], "preservesAuthoredDynamicValues": true }, + "securityInvariants": ["AllCommandsVisible", "UnknownPolicyValueFailsClosed"] + } + }, + { + "id": "bash-for-candidate-cap-32", + "concern": "Finite candidate domain at the fixed cap", + "input": "for f in v01 v02 v03 v04 v05 v06 v07 v08 v09 v10 v11 v12 v13 v14 v15 v16 v17 v18 v19 v20 v21 v22 v23 v24 v25 v26 v27 v28 v29 v30 v31 v32; do printf '%s\\n' \"$f\"; done", + "current": { "isUnparseable": true, "reasonContains": "'for'" }, + "desired": { + "syntax": [ + { "id": "root", "kind": "Block", "slot": "Root" }, + { "id": "loop", "kind": "ForEach", "parent": "root", "slot": "Statement", "binding": "f" }, + { "id": "body", "kind": "Block", "parent": "loop", "slot": "Body" }, + { "id": "printf", "kind": "SimpleCommand", "parent": "body", "slot": "Statement", "commandIndex": 0 } + ], + "commands": [ + { + "authoredVerb": "printf", + "immediateRole": "LoopBody", + "ancestry": ["root", "loop", "body"], + "isComplete": true, + "effectiveValues": [ + { "sourceElement": "\"$f\"", "kind": "FiniteSet", "values": ["v01", "v02", "v03", "v04", "v05", "v06", "v07", "v08", "v09", "v10", "v11", "v12", "v13", "v14", "v15", "v16", "v17", "v18", "v19", "v20", "v21", "v22", "v23", "v24", "v25", "v26", "v27", "v28", "v29", "v30", "v31", "v32"], "isPolicySensitive": false } + ] + } + ], + "compatibility": { "verbs": ["printf"], "preservesAuthoredDynamicValues": true }, + "securityInvariants": ["AllCommandsVisible", "EveryFiniteCandidateEvaluated"] + } + }, + { + "id": "bash-for-candidate-overflow-33", + "concern": "Candidate overflow collapses instead of truncating", + "input": "for f in v01 v02 v03 v04 v05 v06 v07 v08 v09 v10 v11 v12 v13 v14 v15 v16 v17 v18 v19 v20 v21 v22 v23 v24 v25 v26 v27 v28 v29 v30 v31 v32 v33; do printf '%s\\n' \"$f\"; done", + "current": { "isUnparseable": true, "reasonContains": "'for'" }, + "desired": { + "syntax": [ + { "id": "root", "kind": "Block", "slot": "Root" }, + { "id": "loop", "kind": "ForEach", "parent": "root", "slot": "Statement", "binding": "f" }, + { "id": "body", "kind": "Block", "parent": "loop", "slot": "Body" }, + { "id": "printf", "kind": "SimpleCommand", "parent": "body", "slot": "Statement", "commandIndex": 0 } + ], + "commands": [ + { + "authoredVerb": "printf", + "immediateRole": "LoopBody", + "ancestry": ["root", "loop", "body"], + "isComplete": true, + "effectiveValues": [ + { "sourceElement": "\"$f\"", "kind": "Unknown", "isPolicySensitive": false } + ] + } + ], + "compatibility": { "verbs": ["printf"], "preservesAuthoredDynamicValues": true }, + "securityInvariants": ["AllCommandsVisible", "UnknownPolicyValueFailsClosed"] + } + }, + { + "id": "bash-heredoc-body-is-data", + "concern": "Literal heredoc data does not create hidden commands or unnecessary approval scope", + "input": "cat > output.txt <<'EOF'\nhello\nEOF\n", + "current": { "isUnparseable": true, "reasonContains": "missing delimiter" }, + "desired": { + "syntax": [ + { "id": "root", "kind": "Block", "slot": "Root" }, + { "id": "cat", "kind": "SimpleCommand", "parent": "root", "slot": "Statement", "commandIndex": 0 } + ], + "commands": [ + { + "authoredVerb": "cat", + "immediateRole": "Ordinary", + "ancestry": ["root"], + "isComplete": true, + "redirects": [ + { + "operation": "FileOutput", + "target": { "sourceElement": "output.txt", "kind": "Exact", "values": ["output.txt"], "isPolicySensitive": true }, + "isPathRelevant": true, + "isComplete": true + }, + { + "operation": "HereDocument", + "hereDocument": { + "delimiterRaw": "'EOF'", + "bodyRaw": "hello\n", + "expansionMode": "Literal", + "stripLeadingTabs": false, + "isComplete": true + }, + "isPathRelevant": false, + "isComplete": true + } + ] + } + ], + "compatibility": { "verbs": ["cat"], "preservesAuthoredDynamicValues": false }, + "securityInvariants": ["AllCommandsVisible", "OpaqueDataNotExecuted", "StaticRedirectNotDynamic"] + }, + "notes": "v0.3 fixes quoted-delimiter adjacency, preserves the literal body as data, and keeps the output path independently policy-relevant." + }, + { + "id": "bash-here-string-literal-data", + "concern": "Static here-string data does not force a raw-command approval prompt", + "input": "cat <<< \"hello\"", + "current": { "isUnparseable": true, "reasonContains": "missing delimiter" }, + "desired": { + "syntax": [ + { "id": "root", "kind": "Block", "slot": "Root" }, + { "id": "cat", "kind": "SimpleCommand", "parent": "root", "slot": "Statement", "commandIndex": 0 } + ], + "commands": [ + { + "authoredVerb": "cat", + "immediateRole": "Ordinary", + "ancestry": ["root"], + "isComplete": true, + "redirects": [ + { + "operation": "HereString", + "target": { "sourceElement": "\"hello\"", "kind": "Exact", "values": ["hello\n"], "isPolicySensitive": false }, + "isPathRelevant": false, + "isComplete": true + } + ] + } + ], + "compatibility": { "verbs": ["cat"], "preservesAuthoredDynamicValues": false }, + "securityInvariants": ["AllCommandsVisible", "OpaqueDataNotExecuted", "StaticRedirectNotDynamic"] + }, + "notes": "The exact value includes Bash's appended newline; an executable-aware consumer may ignore stdin data only when that data is not policy-sensitive for the receiver." + }, + { + "id": "bash-here-string-dynamic-data", + "concern": "Unknown stdin data does not make an otherwise complete redirect structurally incomplete", + "input": "cat <<< \"$value\"", + "current": { "isUnparseable": true, "reasonContains": "missing delimiter" }, + "desired": { + "syntax": [ + { "id": "root", "kind": "Block", "slot": "Root" }, + { "id": "cat", "kind": "SimpleCommand", "parent": "root", "slot": "Statement", "commandIndex": 0 } + ], + "commands": [ + { + "authoredVerb": "cat", + "immediateRole": "Ordinary", + "ancestry": ["root"], + "isComplete": true, + "redirects": [ + { + "operation": "HereString", + "target": { "sourceElement": "\"$value\"", "kind": "Unknown", "isPolicySensitive": false }, + "isPathRelevant": false, + "isComplete": true + } + ] + } + ], + "compatibility": { "verbs": ["cat"], "preservesAuthoredDynamicValues": true }, + "securityInvariants": ["AllCommandsVisible", "OpaqueDataNotExecuted"] + }, + "notes": "The unknown value is not automatically approval-sensitive for cat, but a consumer can classify the same stdin position as sensitive for an interpreter such as bash." + } + ] +} diff --git a/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/powershell.json b/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/powershell.json new file mode 100644 index 0000000..97001e2 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/powershell.json @@ -0,0 +1,490 @@ +{ + "shell": "PowerShell", + "cases": [ + { + "id": "pwsh-foreach-literal-array", + "concern": "Finite literal foreach binding", + "input": "foreach ($f in @('a.txt', 'b.txt')) { Remove-Item -LiteralPath $f }", + "current": { "isUnparseable": true, "reasonContains": "'foreach'" }, + "desired": { + "syntax": [ + { "id": "root", "kind": "Block", "slot": "Root" }, + { "id": "loop", "kind": "ForEach", "parent": "root", "slot": "Statement", "binding": "f" }, + { "id": "body", "kind": "Block", "parent": "loop", "slot": "Body" }, + { "id": "remove", "kind": "SimpleCommand", "parent": "body", "slot": "Statement", "commandIndex": 0 } + ], + "commands": [ + { + "authoredVerb": "Remove-Item", + "immediateRole": "LoopBody", + "ancestry": ["root", "loop", "body"], + "isComplete": true, + "effectiveValues": [ + { "sourceElement": "$f", "kind": "FiniteSet", "values": ["a.txt", "b.txt"], "isPolicySensitive": true } + ] + } + ], + "compatibility": { "verbs": ["Remove-Item"], "preservesAuthoredDynamicValues": true }, + "securityInvariants": ["AllCommandsVisible", "EveryFiniteCandidateEvaluated", "ShellSpecificOptionSemantics"] + } + }, + { + "id": "pwsh-foreach-pipeline-iterator", + "concern": "Iterator command produces unknown PowerShell objects", + "input": "foreach ($f in Get-ChildItem C:\\input) { Remove-Item -LiteralPath $f }", + "current": { "isUnparseable": true, "reasonContains": "'foreach'" }, + "desired": { + "syntax": [ + { "id": "root", "kind": "Block", "slot": "Root" }, + { "id": "loop", "kind": "ForEach", "parent": "root", "slot": "Statement", "binding": "f" }, + { "id": "iterator", "kind": "Block", "parent": "loop", "slot": "Iterator" }, + { "id": "get", "kind": "SimpleCommand", "parent": "iterator", "slot": "Statement", "commandIndex": 0 }, + { "id": "body", "kind": "Block", "parent": "loop", "slot": "Body" }, + { "id": "remove", "kind": "SimpleCommand", "parent": "body", "slot": "Statement", "commandIndex": 1 } + ], + "commands": [ + { "authoredVerb": "Get-ChildItem", "immediateRole": "Iterator", "ancestry": ["root", "loop", "iterator"], "isComplete": true }, + { + "authoredVerb": "Remove-Item", + "immediateRole": "LoopBody", + "ancestry": ["root", "loop", "body"], + "isComplete": true, + "effectiveValues": [ + { "sourceElement": "$f", "kind": "Unknown", "isPolicySensitive": true } + ] + } + ], + "compatibility": { "verbs": ["Get-ChildItem", "Remove-Item"], "preservesAuthoredDynamicValues": true }, + "securityInvariants": ["AllCommandsVisible", "IteratorCommandsVisible", "UnknownPolicyValueFailsClosed"] + }, + "notes": "The iterator emits filesystem objects, not proved literal strings." + }, + { + "id": "pwsh-foreach-cmdlet-parameter-like-value", + "concern": "Expanded cmdlet value is not a syntactic parameter token", + "input": "foreach ($f in @('-Force', 'a.txt')) { Remove-Item $f }", + "current": { "isUnparseable": true, "reasonContains": "'foreach'" }, + "desired": { + "syntax": [ + { "id": "root", "kind": "Block", "slot": "Root" }, + { "id": "loop", "kind": "ForEach", "parent": "root", "slot": "Statement", "binding": "f" }, + { "id": "body", "kind": "Block", "parent": "loop", "slot": "Body" }, + { "id": "remove", "kind": "SimpleCommand", "parent": "body", "slot": "Statement", "commandIndex": 0 } + ], + "commands": [ + { + "authoredVerb": "Remove-Item", + "immediateRole": "LoopBody", + "ancestry": ["root", "loop", "body"], + "isComplete": true, + "effectiveValues": [ + { "sourceElement": "$f", "kind": "FiniteSet", "values": ["-Force", "a.txt"], "isPolicySensitive": true } + ] + } + ], + "compatibility": { "verbs": ["Remove-Item"], "preservesAuthoredDynamicValues": true }, + "securityInvariants": ["EveryFiniteCandidateEvaluated", "ShellSpecificOptionSemantics"] + }, + "notes": "PowerShell parameter binding is syntax-aware; this differs from passing a leading-hyphen value to a native executable." + }, + { + "id": "pwsh-foreach-native-option-like-value", + "concern": "Finite value can affect native option parsing", + "input": "foreach ($f in @('-n', 'file.txt')) { tool $f }", + "current": { "isUnparseable": true, "reasonContains": "'foreach'" }, + "desired": { + "syntax": [ + { "id": "root", "kind": "Block", "slot": "Root" }, + { "id": "loop", "kind": "ForEach", "parent": "root", "slot": "Statement", "binding": "f" }, + { "id": "body", "kind": "Block", "parent": "loop", "slot": "Body" }, + { "id": "tool", "kind": "SimpleCommand", "parent": "body", "slot": "Statement", "commandIndex": 0 } + ], + "commands": [ + { + "authoredVerb": "tool", + "immediateRole": "LoopBody", + "ancestry": ["root", "loop", "body"], + "isComplete": true, + "effectiveValues": [ + { "sourceElement": "$f", "kind": "FiniteSet", "values": ["-n", "file.txt"], "isPolicySensitive": true } + ] + } + ], + "compatibility": { "verbs": ["tool"], "preservesAuthoredDynamicValues": true }, + "securityInvariants": ["EveryFiniteCandidateEvaluated", "ShellSpecificOptionSemantics"] + } + }, + { + "id": "pwsh-nested-foreach", + "concern": "Nested finite domains and structural ancestry", + "input": "foreach ($d in @('a', 'b')) { foreach ($f in @('x', 'y')) { Write-Output \"$d/$f\" } }", + "current": { "isUnparseable": true, "reasonContains": "'foreach'" }, + "desired": { + "syntax": [ + { "id": "root", "kind": "Block", "slot": "Root" }, + { "id": "outer", "kind": "ForEach", "parent": "root", "slot": "Statement", "binding": "d" }, + { "id": "outerBody", "kind": "Block", "parent": "outer", "slot": "Body" }, + { "id": "inner", "kind": "ForEach", "parent": "outerBody", "slot": "Statement", "binding": "f" }, + { "id": "innerBody", "kind": "Block", "parent": "inner", "slot": "Body" }, + { "id": "write", "kind": "SimpleCommand", "parent": "innerBody", "slot": "Statement", "commandIndex": 0 } + ], + "commands": [ + { + "authoredVerb": "Write-Output", + "immediateRole": "LoopBody", + "ancestry": ["root", "outer", "outerBody", "inner", "innerBody"], + "isComplete": true, + "effectiveValues": [ + { "sourceElement": "\"$d/$f\"", "kind": "FiniteSet", "values": ["a/x", "a/y", "b/x", "b/y"], "isPolicySensitive": false } + ] + } + ], + "compatibility": { "verbs": ["Write-Output"], "preservesAuthoredDynamicValues": true }, + "securityInvariants": ["AllCommandsVisible", "EveryFiniteCandidateEvaluated"] + } + }, + { + "id": "pwsh-foreach-body-pipeline", + "concern": "Pipeline roles inside foreach ancestry", + "input": "foreach ($f in @('a', 'b')) { Write-Output $f | Sort-Object }", + "current": { "isUnparseable": true, "reasonContains": "'foreach'" }, + "desired": { + "syntax": [ + { "id": "root", "kind": "Block", "slot": "Root" }, + { "id": "loop", "kind": "ForEach", "parent": "root", "slot": "Statement", "binding": "f" }, + { "id": "body", "kind": "Block", "parent": "loop", "slot": "Body" }, + { "id": "pipe", "kind": "Pipeline", "parent": "body", "slot": "Statement" }, + { "id": "write", "kind": "SimpleCommand", "parent": "pipe", "slot": "Stage", "commandIndex": 0 }, + { "id": "sort", "kind": "SimpleCommand", "parent": "pipe", "slot": "Stage", "commandIndex": 1 } + ], + "commands": [ + { + "authoredVerb": "Write-Output", + "immediateRole": "PipelineStage", + "ancestry": ["root", "loop", "body", "pipe"], + "isComplete": true, + "effectiveValues": [ + { "sourceElement": "$f", "kind": "FiniteSet", "values": ["a", "b"], "isPolicySensitive": false } + ] + }, + { "authoredVerb": "Sort-Object", "immediateRole": "PipelineStage", "ancestry": ["root", "loop", "body", "pipe"], "isComplete": true } + ], + "compatibility": { "verbs": ["Write-Output", "Sort-Object"], "preservesAuthoredDynamicValues": true }, + "securityInvariants": ["AllCommandsVisible", "EveryFiniteCandidateEvaluated", "NoSyntheticOperator"] + } + }, + { + "id": "pwsh-pipeline-foreach-alias", + "concern": "Contextual foreach alias is not a foreach statement", + "input": "Get-ChildItem | foreach { Remove-Item $_ }", + "current": { "isUnparseable": false }, + "desired": { + "syntax": [ + { "id": "root", "kind": "Block", "slot": "Root" }, + { "id": "pipe", "kind": "Pipeline", "parent": "root", "slot": "Statement" }, + { "id": "get", "kind": "SimpleCommand", "parent": "pipe", "slot": "Stage", "commandIndex": 0 }, + { "id": "foreachAlias", "kind": "SimpleCommand", "parent": "pipe", "slot": "Stage", "commandIndex": 1 }, + { "id": "scriptBlock", "kind": "OpaqueArgument", "parent": "foreachAlias", "slot": "Argument" } + ], + "commands": [ + { "authoredVerb": "Get-ChildItem", "immediateRole": "PipelineStage", "ancestry": ["root", "pipe"], "isComplete": true }, + { + "authoredVerb": "foreach", + "canonicalVerb": "ForEach-Object", + "immediateRole": "PipelineStage", + "ancestry": ["root", "pipe"], + "isComplete": true, + "effectiveValues": [ + { "sourceElement": "{ Remove-Item $_ }", "kind": "Unknown", "isPolicySensitive": true } + ] + } + ], + "compatibility": { "verbs": ["Get-ChildItem", "foreach"], "preservesAuthoredDynamicValues": true }, + "securityInvariants": ["ContextualKeywordNotControlFlow", "OpaqueDataNotExecuted", "UnknownPolicyValueFailsClosed"] + }, + "notes": "ShellSyntaxTree does not assume that a ForEach-Object script-block argument executes as an independently authorized shell command." + }, + { + "id": "pwsh-malformed-foreach-missing-body", + "concern": "Missing statement body safe-fail", + "input": "foreach ($f in 1, 2) Remove-Item $f", + "current": { "isUnparseable": true, "reasonContains": "'foreach'" }, + "desired": { + "isUnparseable": true, + "syntax": [ + { "id": "root", "kind": "Unsupported", "slot": "Root" } + ], + "securityInvariants": ["PartialTreeDiagnosticOnly", "UnknownPolicyValueFailsClosed"] + } + }, + { + "id": "pwsh-while-command-condition", + "concern": "PowerShell condition command visibility", + "input": "while (Test-Path marker.txt) { Remove-Item marker.txt }", + "current": { "isUnparseable": true, "reasonContains": "'while'" }, + "desired": { + "syntax": [ + { "id": "root", "kind": "Block", "slot": "Root" }, + { "id": "loop", "kind": "ConditionLoop", "parent": "root", "slot": "Statement" }, + { "id": "condition", "kind": "Block", "parent": "loop", "slot": "Condition" }, + { "id": "test", "kind": "SimpleCommand", "parent": "condition", "slot": "Statement", "commandIndex": 0 }, + { "id": "body", "kind": "Block", "parent": "loop", "slot": "Body" }, + { "id": "remove", "kind": "SimpleCommand", "parent": "body", "slot": "Statement", "commandIndex": 1 } + ], + "commands": [ + { "authoredVerb": "Test-Path", "immediateRole": "Condition", "ancestry": ["root", "loop", "condition"], "isComplete": true }, + { "authoredVerb": "Remove-Item", "immediateRole": "LoopBody", "ancestry": ["root", "loop", "body"], "isComplete": true } + ], + "compatibility": { "verbs": ["Test-Path", "Remove-Item"], "preservesAuthoredDynamicValues": false }, + "securityInvariants": ["AllCommandsVisible", "ConditionCommandsVisible"] + } + }, + { + "id": "pwsh-if-location-join", + "concern": "Divergent PowerShell location state", + "input": "if (Test-Path marker.txt) { Set-Location C:\\a } else { Set-Location C:\\b }; Get-ChildItem file.txt", + "current": { "isUnparseable": true, "reasonContains": "'if'" }, + "desired": { + "syntax": [ + { "id": "root", "kind": "Block", "slot": "Root" }, + { "id": "if", "kind": "Conditional", "parent": "root", "slot": "Statement" }, + { "id": "condition", "kind": "Block", "parent": "if", "slot": "Condition" }, + { "id": "test", "kind": "SimpleCommand", "parent": "condition", "slot": "Statement", "commandIndex": 0 }, + { "id": "then", "kind": "Block", "parent": "if", "slot": "Then" }, + { "id": "setA", "kind": "SimpleCommand", "parent": "then", "slot": "Statement", "commandIndex": 1 }, + { "id": "else", "kind": "Block", "parent": "if", "slot": "Else" }, + { "id": "setB", "kind": "SimpleCommand", "parent": "else", "slot": "Statement", "commandIndex": 2 }, + { "id": "get", "kind": "SimpleCommand", "parent": "root", "slot": "Statement", "commandIndex": 3 } + ], + "commands": [ + { "authoredVerb": "Test-Path", "immediateRole": "Condition", "ancestry": ["root", "if", "condition"], "isComplete": true }, + { "authoredVerb": "Set-Location", "immediateRole": "Branch", "ancestry": ["root", "if", "then"], "isComplete": true }, + { "authoredVerb": "Set-Location", "immediateRole": "Branch", "ancestry": ["root", "if", "else"], "isComplete": true }, + { + "authoredVerb": "Get-ChildItem", + "immediateRole": "Ordinary", + "ancestry": ["root"], + "isComplete": true, + "effectiveValues": [ + { "sourceElement": "file.txt", "kind": "Unknown", "isPolicySensitive": true } + ] + } + ], + "compatibility": { "verbs": ["Test-Path", "Set-Location", "Set-Location", "Get-ChildItem"], "preservesAuthoredDynamicValues": true }, + "securityInvariants": ["AllCommandsVisible", "ConditionCommandsVisible", "StateJoinConservative", "UnknownPolicyValueFailsClosed"] + } + }, + { + "id": "pwsh-ordinary-scriptblock-argument", + "concern": "Executable-specific script-block data remains opaque", + "input": "Invoke-Command { Remove-Item secret.txt }", + "current": { "isUnparseable": false }, + "desired": { + "syntax": [ + { "id": "root", "kind": "Block", "slot": "Root" }, + { "id": "invoke", "kind": "SimpleCommand", "parent": "root", "slot": "Statement", "commandIndex": 0 }, + { "id": "scriptBlock", "kind": "OpaqueArgument", "parent": "invoke", "slot": "Argument" } + ], + "commands": [ + { + "authoredVerb": "Invoke-Command", + "immediateRole": "Ordinary", + "ancestry": ["root"], + "isComplete": true, + "effectiveValues": [ + { "sourceElement": "{ Remove-Item secret.txt }", "kind": "Unknown", "isPolicySensitive": true } + ] + } + ], + "compatibility": { "verbs": ["Invoke-Command"], "preservesAuthoredDynamicValues": true }, + "securityInvariants": ["OpaqueDataNotExecuted", "UnknownPolicyValueFailsClosed"] + } + }, + { + "id": "pwsh-foreach-dynamic-command-identity", + "concern": "Dynamic invocation in a bounded loop body", + "input": "foreach ($f in @('a', 'b')) { & $exe $f }", + "current": { "isUnparseable": true, "reasonContains": "'foreach'" }, + "desired": { + "syntax": [ + { "id": "root", "kind": "Block", "slot": "Root" }, + { "id": "loop", "kind": "ForEach", "parent": "root", "slot": "Statement", "binding": "f" }, + { "id": "body", "kind": "Block", "parent": "loop", "slot": "Body" }, + { "id": "dynamic", "kind": "SimpleCommand", "parent": "body", "slot": "Statement", "commandIndex": 0 } + ], + "commands": [ + { + "authoredVerb": "$exe", + "immediateRole": "LoopBody", + "ancestry": ["root", "loop", "body"], + "isComplete": false, + "effectiveValues": [ + { "sourceElement": "$f", "kind": "FiniteSet", "values": ["a", "b"], "isPolicySensitive": true } + ] + } + ], + "compatibility": { "verbs": ["$exe"], "preservesAuthoredDynamicValues": true }, + "securityInvariants": ["AllCommandsVisible", "UnknownPolicyValueFailsClosed"] + } + }, + { + "id": "pwsh-foreach-candidate-cap-32", + "concern": "Finite candidate domain at the fixed cap", + "input": "foreach ($f in @('v01','v02','v03','v04','v05','v06','v07','v08','v09','v10','v11','v12','v13','v14','v15','v16','v17','v18','v19','v20','v21','v22','v23','v24','v25','v26','v27','v28','v29','v30','v31','v32')) { Write-Output $f }", + "current": { "isUnparseable": true, "reasonContains": "'foreach'" }, + "desired": { + "syntax": [ + { "id": "root", "kind": "Block", "slot": "Root" }, + { "id": "loop", "kind": "ForEach", "parent": "root", "slot": "Statement", "binding": "f" }, + { "id": "body", "kind": "Block", "parent": "loop", "slot": "Body" }, + { "id": "write", "kind": "SimpleCommand", "parent": "body", "slot": "Statement", "commandIndex": 0 } + ], + "commands": [ + { + "authoredVerb": "Write-Output", + "immediateRole": "LoopBody", + "ancestry": ["root", "loop", "body"], + "isComplete": true, + "effectiveValues": [ + { "sourceElement": "$f", "kind": "FiniteSet", "values": ["v01", "v02", "v03", "v04", "v05", "v06", "v07", "v08", "v09", "v10", "v11", "v12", "v13", "v14", "v15", "v16", "v17", "v18", "v19", "v20", "v21", "v22", "v23", "v24", "v25", "v26", "v27", "v28", "v29", "v30", "v31", "v32"], "isPolicySensitive": false } + ] + } + ], + "compatibility": { "verbs": ["Write-Output"], "preservesAuthoredDynamicValues": true }, + "securityInvariants": ["AllCommandsVisible", "EveryFiniteCandidateEvaluated"] + } + }, + { + "id": "pwsh-foreach-candidate-overflow-33", + "concern": "Candidate overflow collapses instead of truncating", + "input": "foreach ($f in @('v01','v02','v03','v04','v05','v06','v07','v08','v09','v10','v11','v12','v13','v14','v15','v16','v17','v18','v19','v20','v21','v22','v23','v24','v25','v26','v27','v28','v29','v30','v31','v32','v33')) { Write-Output $f }", + "current": { "isUnparseable": true, "reasonContains": "'foreach'" }, + "desired": { + "syntax": [ + { "id": "root", "kind": "Block", "slot": "Root" }, + { "id": "loop", "kind": "ForEach", "parent": "root", "slot": "Statement", "binding": "f" }, + { "id": "body", "kind": "Block", "parent": "loop", "slot": "Body" }, + { "id": "write", "kind": "SimpleCommand", "parent": "body", "slot": "Statement", "commandIndex": 0 } + ], + "commands": [ + { + "authoredVerb": "Write-Output", + "immediateRole": "LoopBody", + "ancestry": ["root", "loop", "body"], + "isComplete": true, + "effectiveValues": [ + { "sourceElement": "$f", "kind": "Unknown", "isPolicySensitive": false } + ] + } + ], + "compatibility": { "verbs": ["Write-Output"], "preservesAuthoredDynamicValues": true }, + "securityInvariants": ["AllCommandsVisible", "UnknownPolicyValueFailsClosed"] + } + }, + { + "id": "pwsh-static-stream-merge", + "concern": "Static PowerShell stream merge", + "input": "Get-ChildItem 2>&1", + "current": { "isUnparseable": false }, + "desired": { + "syntax": [ + { "id": "root", "kind": "Block", "slot": "Root" }, + { "id": "get", "kind": "SimpleCommand", "parent": "root", "slot": "Statement", "commandIndex": 0 } + ], + "commands": [ + { + "authoredVerb": "Get-ChildItem", + "immediateRole": "Ordinary", + "ancestry": ["root"], + "isComplete": true, + "redirects": [ + { "operation": "DescriptorDuplicate", "sourceDescriptor": 2, "targetDescriptor": 1, "isPathRelevant": false, "isComplete": true } + ] + } + ], + "compatibility": { "verbs": ["Get-ChildItem"], "preservesAuthoredDynamicValues": true }, + "securityInvariants": ["AllCommandsVisible", "StaticRedirectNotDynamic"] + } + }, + { + "id": "pwsh-contextual-foreach-argument", + "concern": "Control keyword used as ordinary argument", + "input": "Write-Output foreach", + "current": { "isUnparseable": false }, + "desired": { + "syntax": [ + { "id": "root", "kind": "Block", "slot": "Root" }, + { "id": "write", "kind": "SimpleCommand", "parent": "root", "slot": "Statement", "commandIndex": 0 } + ], + "commands": [ + { "authoredVerb": "Write-Output", "immediateRole": "Ordinary", "ancestry": ["root"], "isComplete": true } + ], + "compatibility": { "verbs": ["Write-Output"], "preservesAuthoredDynamicValues": false }, + "securityInvariants": ["AllCommandsVisible", "ContextualKeywordNotControlFlow"] + } + }, + { + "id": "pwsh-foreach-location-zero-iteration-join", + "concern": "Loop exit includes zero-iteration location state", + "input": "foreach ($d in @('C:\\a', 'C:\\b')) { Set-Location $d }; Get-ChildItem file.txt", + "current": { "isUnparseable": true, "reasonContains": "'foreach'" }, + "desired": { + "syntax": [ + { "id": "root", "kind": "Block", "slot": "Root" }, + { "id": "loop", "kind": "ForEach", "parent": "root", "slot": "Statement", "binding": "d" }, + { "id": "body", "kind": "Block", "parent": "loop", "slot": "Body" }, + { "id": "set", "kind": "SimpleCommand", "parent": "body", "slot": "Statement", "commandIndex": 0 }, + { "id": "get", "kind": "SimpleCommand", "parent": "root", "slot": "Statement", "commandIndex": 1 } + ], + "commands": [ + { + "authoredVerb": "Set-Location", + "immediateRole": "LoopBody", + "ancestry": ["root", "loop", "body"], + "isComplete": true, + "effectiveValues": [ + { "sourceElement": "$d", "kind": "FiniteSet", "values": ["C:\\a", "C:\\b"], "isPolicySensitive": true } + ] + }, + { + "authoredVerb": "Get-ChildItem", + "immediateRole": "Ordinary", + "ancestry": ["root"], + "isComplete": true, + "effectiveValues": [ + { "sourceElement": "file.txt", "kind": "Unknown", "isPolicySensitive": true } + ] + } + ], + "compatibility": { "verbs": ["Set-Location", "Get-ChildItem"], "preservesAuthoredDynamicValues": true }, + "securityInvariants": ["AllCommandsVisible", "EveryFiniteCandidateEvaluated", "StateJoinConservative", "UnknownPolicyValueFailsClosed"] + } + }, + { + "id": "pwsh-literal-here-string-is-value", + "concern": "PowerShell here-string syntax remains an argument rather than a redirect", + "input": "Write-Output @'\n$literal text\n'@", + "current": { "isUnparseable": false }, + "desired": { + "syntax": [ + { "id": "root", "kind": "Block", "slot": "Root" }, + { "id": "write", "kind": "SimpleCommand", "parent": "root", "slot": "Statement", "commandIndex": 0 } + ], + "commands": [ + { + "authoredVerb": "Write-Output", + "immediateRole": "Ordinary", + "ancestry": ["root"], + "isComplete": true, + "effectiveValues": [ + { "sourceElement": "@'\n$literal text\n'@", "kind": "Exact", "values": ["$literal text"], "isPolicySensitive": false } + ] + } + ], + "compatibility": { "verbs": ["Write-Output"], "preservesAuthoredDynamicValues": false }, + "securityInvariants": ["AllCommandsVisible", "OpaqueDataNotExecuted", "ShellSpecificOptionSemantics"] + }, + "notes": "PowerShell's literal here string does not interpolate the dollar expression and does not produce a RedirectAnalysis entry." + } + ] +} diff --git a/tests/ShellSyntaxTree.Tests/ShellSyntaxTree.Tests.csproj b/tests/ShellSyntaxTree.Tests/ShellSyntaxTree.Tests.csproj index ddb2d0d..cfe21ee 100644 --- a/tests/ShellSyntaxTree.Tests/ShellSyntaxTree.Tests.csproj +++ b/tests/ShellSyntaxTree.Tests/ShellSyntaxTree.Tests.csproj @@ -31,6 +31,9 @@ PreserveNewest + + PreserveNewest +