From af6d61e2487e5e6f98392325cdf565ef6dcfa4c0 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Sun, 9 Aug 2026 01:13:21 +0000 Subject: [PATCH] Close Bash command resolution mutation boundary --- IMPLEMENTATION_PLAN.md | 9 + SPEC.md | 25 +- docs/CONSUMER_GUIDE.md | 12 + .../v0-3-structured-shell-analysis/design.md | 30 ++- .../proposal.md | 5 + .../specs/bounded-shell-analysis/spec.md | 60 +++++ .../v0-3-structured-shell-analysis/tasks.md | 3 + .../Bash/Parsing/BashCwdInvocationGrammar.cs | 218 ++++++++++++++++++ .../Bash/Parsing/BashStructuralCoordinator.cs | 13 ++ .../bash/273_v03_hash_mapping_rejected.json | 10 + .../274_v03_alias_activation_rejected.json | 10 + .../Corpus/bash/275_v03_exec_rejected.json | 10 + .../276_v03_enable_mutation_rejected.json | 10 + .../bash/277_v03_time_prefix_rejected.json | 10 + .../278_v03_negation_prefix_rejected.json | 10 + .../bash/279_v03_brace_group_rejected.json | 10 + .../Parsing/BashExecutionBoundaryTests.cs | 126 ++++++++++ .../Parsing/ShellValueOracleTests.cs | 126 ++++++++++ 18 files changed, 693 insertions(+), 4 deletions(-) create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/bash/273_v03_hash_mapping_rejected.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/bash/274_v03_alias_activation_rejected.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/bash/275_v03_exec_rejected.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/bash/276_v03_enable_mutation_rejected.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/bash/277_v03_time_prefix_rejected.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/bash/278_v03_negation_prefix_rejected.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/bash/279_v03_brace_group_rejected.json diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index edc3e43..1b0df53 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -543,6 +543,15 @@ priorities. fail atomically until their state effects are modeled. Native Bash oracles pin nameref-deferred and integer-assignment execution, and the executable corpus carries isolated, unknown-state, and wrapper cases. +- [x] Close Bash command-resolution mutation globally. Reject `exec` and + mutating or ambiguous `hash`, `alias`, `unalias`, `shopt`, and `enable` + forms before later occurrences can inherit a false executable identity; retain only + exact static query grammar and pin native-shell behavior, recursive + wrappers, and executable corpus cases. Fail unmodeled `time`, negation, + coprocess, and current-shell brace-group syntax closed rather than + flattening nested execution into apparent ordinary verb chains. +- [ ] Promote the Bash command-resolution mutation cases into Netclaw's strict + allow/prompt/deny matrix before the downstream approval-fatigue gate. - [ ] Add the separately tested Bash `<<<` here-string redirect slice with bounded operand analysis and trailing-newline semantics. diff --git a/SPEC.md b/SPEC.md index dc85b9a..c112ea1 100644 --- a/SPEC.md +++ b/SPEC.md @@ -810,8 +810,28 @@ evaluate argument text, install deferred execution, or assign through unproved integer, nameref, or array attributes. Recognition recursively unwraps statically proved `command` and `builtin` dispatch; dynamic or invalid wrapper grammar fails closed. Ordinary `printf` without `-v` remains -supported. `break`, `continue`, `return`, `exit`, and `exec` remain loop-region -failures until their transfers are implemented. +supported. + +Command-resolution state is independent from variable attributes and cwd. +`exec` fails the complete parse closed globally because it replaces the shell +or makes commandless redirections persistent. Mutating or ambiguous `hash`, +`alias`, `unalias`, `shopt`, and `enable` forms likewise fail globally before a +later command can inherit an unmodeled executable identity. The only retained +forms are exact static queries: bare `hash`, `alias`, `shopt`, and `enable`; +`hash -l` without operands and `hash -t NAME...`; `alias [-p] [NAME...]` +without a definition; `shopt` option clusters without `s` or `u`; and no-name +`enable` listing flags composed only from `a`, `n`, `p`, and `s`. Dynamic or +invalid grammar fails closed, and exact `command` / `builtin` wrappers cannot +bypass the boundary. `break`, `continue`, `return`, and `exit` remain +loop-region failures until their transfers are implemented. + +Unquoted `time` and `!` reserved prefixes execute the following pipeline with +current-shell state; `coproc` starts hidden concurrent execution; and +`{ ...; }` is a current-shell group. Stable v0.3 fails exact unquoted `time`, +`!`, `coproc`, `{`, or `}` in command position closed until their nested +structure and state propagation are modeled. Quoted/escaped spellings, +`/usr/bin/time`, and `command time ...` remain ordinary command identities and +do not acquire reserved-word semantics. Substitutions and subshells inherit the current variable/cwd state but discard their state changes on exit. Decoded Bash command wrappers inherit invocation @@ -1356,7 +1376,6 @@ 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_group | bash_subshell | bash_c_wrapper | bash_simple_command diff --git a/docs/CONSUMER_GUIDE.md b/docs/CONSUMER_GUIDE.md index 68a7750..2321259 100644 --- a/docs/CONSUMER_GUIDE.md +++ b/docs/CONSUMER_GUIDE.md @@ -231,6 +231,18 @@ outside the first bounded scalar grammar. The parser also downgrades a decoded `export`; resolver-only option cloning for an exact cwd retains the independent variable-state assertion. +ShellSyntaxTree also treats Bash command resolution as parser-owned security +state. `exec` and mutating or ambiguous `hash`, `alias`, `unalias`, `shopt`, +and `enable` forms make the complete result unparseable, including through exact `command` +or `builtin` dispatch wrappers. Only documented static query forms remain +visible, such as `hash -t name`, `alias name`, `shopt -q option`, and bare +`enable -n`. Consumers need no special fallback for rejected mutations: apply +the ordinary `IsUnparseable` prompt-or-deny rule. A parseable query is still +only syntax evidence; it does not prove the queried executable safe. +Unmodeled unquoted `time`, `!`, `coproc`, and `{ ...; }` syntax follows the +same rule because those constructs can hide nested or current-shell execution; +quoted spellings and external `/usr/bin/time` do not acquire reserved syntax. + PowerShell `foreach` value proofs require the parallel but shell-specific assertion. `PwshInitialStateMode.Unknown` is the safe default: the parser can still expose supported loop structure, but ambient typed, validated, diff --git a/openspec/changes/v0-3-structured-shell-analysis/design.md b/openspec/changes/v0-3-structured-shell-analysis/design.md index c7f9c07..1827af6 100644 --- a/openspec/changes/v0-3-structured-shell-analysis/design.md +++ b/openspec/changes/v0-3-structured-shell-analysis/design.md @@ -567,6 +567,35 @@ than bypassing the catalog. Ordinary `printf` without `-v` remains supported. A later additive grammar may admit individually proved query or assignment forms without weakening this stable boundary. +Bash command resolution is a separate state axis from variable attributes and +cwd. Stable v0.3 rejects `exec` globally because it replaces the analyzed shell +with another executable (or makes redirections persistent when no command is +supplied). It also rejects `unalias` and every mutating or ambiguous `hash`, +`alias`, `shopt`, and `enable` form before any later occurrence can inherit a +false command identity. This closes mappings such as +`hash -p /bin/rm git; git target`, newline-delimited alias activation through +`shopt -s expand_aliases`, and builtin disable/load operations. + +The parser may retain only exact static query grammar: bare `hash`, `alias`, +`shopt`, and `enable`; `hash -l` without operands and `hash -t NAME...`; +`alias [-p] [NAME...]` without an equals-bearing definition; `shopt` options +that contain neither `-s` nor `-u`; and no-name `enable` listing flags composed +only from `-a`, `-n`, `-p`, and `-s`. Dynamic words, unsupported options, plain +hash names, alias definitions, any `shopt -s` / `-u`, and any enable name, +`-d`, or `-f` fail closed. Exact `command` and `builtin` wrappers are unwrapped +before this grammar is applied. Query recognition is a parser completeness +fact, not authorization for the queried builtin. + +Reserved execution syntax is not a verb-chain wrapper. Unquoted `time` and `!` +run the following pipeline with current-shell state, `coproc` launches hidden +concurrent execution, and `{ ...; }` shares the current shell. Until those +constructs have typed structural nodes and recursive state analysis, an exact +unquoted `time`, `!`, `coproc`, `{`, or `}` in command position fails the +complete parse closed. Quoted/escaped spellings and `/usr/bin/time` remain +ordinary command identities; `command time ...` does not invent reserved-word +semantics. The structured Bash BNF therefore does not include a `bash_group` +production until brace-group support is actually implemented. + PowerShell needs the same explicit boundary for different reasons. `PwshParserOptions.InitialStateMode` defaults to `Unknown`, which permits structural discovery but withholds exact or finite `foreach` binding proofs. @@ -1505,7 +1534,6 @@ 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_group | bash_subshell | bash_c_wrapper | bash_simple_command diff --git a/openspec/changes/v0-3-structured-shell-analysis/proposal.md b/openspec/changes/v0-3-structured-shell-analysis/proposal.md index 111a6f5..3287426 100644 --- a/openspec/changes/v0-3-structured-shell-analysis/proposal.md +++ b/openspec/changes/v0-3-structured-shell-analysis/proposal.md @@ -34,6 +34,11 @@ fail-closed behavior for incomplete analysis. unmodeled shell builtins that can evaluate, assign through, or defer argument text, because nameref and arithmetic attributes can otherwise turn quoted data into hidden execution. +- Treat Bash command resolution as policy-relevant shell state. `exec` and + mutating or ambiguous `hash`, `alias`, `unalias`, shell-option, and + builtin-enable forms fail closed globally; only exact documented query forms remain visible. + Unmodeled `time`, negation, coprocess, and brace-group syntax also fails + closed rather than hiding a nested mutation in an apparent verb chain. - Preserve resolver-relevant lexical fragments, typed expansion identity and cardinality, operation-specific transform eligibility, opaque cause, and consumer/binding context through decoding so escaped or 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 e765602..30f372b 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 @@ -224,6 +224,66 @@ SHALL retain its existing behavior. - **WHEN** Bash parses `builtin eval 'rm target.txt'` - **THEN** the whole result is unparseable under the same rule as direct `eval` +### Requirement: Bash command-resolution mutation fails closed globally +Stable v0.3 SHALL treat command-resolution state independently from variable +attributes and cwd. Direct or statically wrapped `exec` SHALL make the whole +result unparseable. Mutating or ambiguous `hash`, `alias`, `unalias`, `shopt`, +and `enable` forms SHALL likewise fail closed before later commands can inherit +an unmodeled executable identity. + +The parser MAY retain only exact static query forms: bare `hash`, `alias`, +`shopt`, and `enable`; `hash -l` without operands and `hash -t NAME...`; +`alias [-p] [NAME...]` without an equals-bearing definition; `shopt` option +clusters that contain neither `s` nor `u`; and no-name `enable` listing flags +composed only from `a`, `n`, `p`, and `s`. A dynamic word, invalid option, +plain hash operand, alias definition, `unalias`, `shopt -s` / `-u`, or enable +name / `-d` / `-f` SHALL make the complete result unparseable. Exact `command` +and `builtin` dispatch wrappers SHALL NOT bypass this boundary. + +#### Scenario: Hash mapping cannot replace a later executable +- **WHEN** Bash parses `hash -p /bin/rm git; git target.txt` +- **THEN** the whole result is unparseable +- **THEN** the later `git` occurrence is not published with a false executable identity + +#### Scenario: Alias activation cannot hide a later command +- **WHEN** Bash parses newline-delimited `shopt -s expand_aliases`, `alias safe=rm`, and `safe target.txt` +- **THEN** the whole result is unparseable before the alias-expanded command can be hidden + +#### Scenario: Exec is a global execution boundary +- **WHEN** Bash parses `exec /bin/rm target.txt` outside a loop +- **THEN** the whole result is unparseable +- **THEN** `exec` is not published as an ordinary complete occurrence + +#### Scenario: Exact query forms remain structurally visible +- **WHEN** Bash parses `hash -t git`, `alias safe`, `shopt -q expand_aliases`, or bare `enable -n` +- **THEN** the query remains parseable under its exact option grammar +- **THEN** the parser does not infer that the queried identity is safe to authorize + +### Requirement: Unmodeled Bash reserved execution syntax fails closed +Stable v0.3 SHALL NOT treat unquoted `time`, `!`, `coproc`, `{`, or `}` in +command position as ordinary verb-chain elements. Until timed/negated +pipelines, coprocesses, and brace groups have typed recursive structure and +state propagation, each form SHALL make the whole result unparseable. Quoted +or escaped spellings and an external `/usr/bin/time` identity SHALL NOT invent +reserved syntax. + +#### Scenario: Timed mutation remains in the current shell +- **WHEN** Bash parses `time hash -p /bin/rm git; git target.txt` +- **THEN** the whole result is unparseable +- **THEN** the hash mutation is not hidden inside a `time hash` verb chain + +#### Scenario: Negated mutation remains in the current shell +- **WHEN** Bash parses `! hash -p /bin/rm git; git target.txt` +- **THEN** the whole result is unparseable even though negation changes exit status + +#### Scenario: Brace group shares command-resolution state +- **WHEN** Bash parses `{ hash -p /bin/rm git; git target.txt; }` +- **THEN** the whole result is unparseable until current-scope brace groups are modeled + +#### Scenario: Coprocess body is hidden concurrent execution +- **WHEN** Bash parses `coproc exec /bin/rm target.txt` +- **THEN** the whole result is unparseable until coprocess structure and timing are modeled + ### Requirement: PowerShell loop proofs require an explicit initial-runspace contract `PwshParserOptions.InitialStateMode` SHALL default to `Unknown`. In that mode, the parser MAY expose supported `foreach` structure and command occurrences, diff --git a/openspec/changes/v0-3-structured-shell-analysis/tasks.md b/openspec/changes/v0-3-structured-shell-analysis/tasks.md index 6617c7b..0968950 100644 --- a/openspec/changes/v0-3-structured-shell-analysis/tasks.md +++ b/openspec/changes/v0-3-structured-shell-analysis/tasks.md @@ -46,6 +46,7 @@ - [x] 3.10a Implement the simple-command argument and redirect-target slice, including comment-safe boundaries and fail-closed unsupported interiors. - [x] 3.10b Implement the bounded expanding-heredoc slice with quote-removed delimiters, literal quoted/escaped bodies, tab stripping, exact provenance, and fail-closed unsupported header/body forms. - [x] 3.10c Require proved Bash variable-attribute state for simple named-parameter dereferences and fail closed globally on the locked unmodeled execution-bearing builtin catalog, including exact dispatch-wrapper bypasses. + - [x] 3.10d Fail closed globally on Bash `exec`, mutating or ambiguous `hash`, alias, `shopt`, and `enable` forms, and unmodeled reserved execution prefixes/groups while retaining only exact static query grammar. - [ ] 3.11 Implement PowerShell `$()` discovery in supported words, redirect values, foreach expressions, call-operator dynamic identities, standalone expression statements, double-quoted strings, and expandable here-strings; never invent invocation from standalone output, retain literal/escaped spellings, and fail closed on trailing command-style arguments, call-operator script blocks, or unsupported execution-bearing `@()` / `@{}` forms. - [x] 3.11a Implement words, redirect values, call-operator dynamic identities, standalone statements, expandable strings/here-strings, and parent-versus-child host payload provenance; fail closed on arbitrary expression values and unsupported execution-bearing `@()` / `@{}` forms. - [x] 3.12 Pin substitution parentage, authored sibling indices, innermost-first ordering, Bash-isolated versus PowerShell-current-scope state, unknown-state propagation, nesting/depth limits, and incomplete dynamic identities in direct tests. @@ -53,11 +54,13 @@ - [x] 3.12b Pin the PowerShell simple-command slice, current-scope exact and unknown cwd propagation, parent/child wrapper provenance, expression boundaries, and the shared structural-depth budget. - [x] 3.12c Pin expanding-heredoc sibling/nested ordering, exact spans, isolated state, delimiter modes, escape parity, depth limits, and atomic failure. - [x] 3.12d Pin default-versus-isolated parameter dereferences, nameref and integer hidden execution, reachable mutation invalidation, substitution/subshell scope boundaries, and direct/wrapped execution-bearing builtins with native Bash oracles. + - [x] 3.12e Pin Bash command-resolution mutation, reserved-prefix/group rejection, and exact-query boundaries with direct tests, recursive wrapper cases, and native Bash oracles. - [ ] 3.13 Promote ordinary, multiple, nested, iterator, redirect, quoted, escaped, stateful, malformed, and hidden-execution substitution cases into both executable corpora and the Netclaw approval matrix. - [x] 3.13a Promote the Bash ordinary, multiple, nested, redirect, quoted, escaped, stateful, malformed, and hidden-execution cases into its executable corpus. - [x] 3.13b Promote the PowerShell ordinary, multiple, nested, redirect, quoted, escaped, stateful, malformed, expression-boundary, and hidden-execution cases into its executable corpus. - [x] 3.13c Promote expanding, literal, tab-stripped, multiple, and malformed Bash heredoc cases with full structural expectations into the executable corpus. - [ ] 3.13d Promote sanitized nameref, unknown-state dereference, and execution-bearing builtin failures into the Bash executable corpus and Netclaw strict matrix. + - [ ] 3.13e Promote sanitized Bash hash, alias/option, exec, builtin-enable, and reserved execution syntax failures into the executable corpus and Netclaw strict matrix. - [x] 3.14 Add `ExecutionRegionSyntax`, its four discriminant enums, `SimpleCommandSyntax.ExecutionRegions`, and appended occurrence/ancestry enum members to the public API and snapshot without changing existing enum values. diff --git a/src/ShellSyntaxTree/Internal/Bash/Parsing/BashCwdInvocationGrammar.cs b/src/ShellSyntaxTree/Internal/Bash/Parsing/BashCwdInvocationGrammar.cs index e92fae1..4f10751 100644 --- a/src/ShellSyntaxTree/Internal/Bash/Parsing/BashCwdInvocationGrammar.cs +++ b/src/ShellSyntaxTree/Internal/Bash/Parsing/BashCwdInvocationGrammar.cs @@ -10,12 +10,23 @@ namespace ShellSyntaxTree.Internal.Bash.Parsing; internal static class BashCwdInvocationGrammar { + private static readonly HashSet UnsupportedReservedExecutionSyntax = + new(StringComparer.Ordinal) + { + "!", + "coproc", + "time", + "{", + "}", + }; + private static readonly HashSet ExecutionBearingBuiltins = new(StringComparer.Ordinal) { ".", "declare", "eval", + "exec", "export", "getopts", "let", @@ -36,6 +47,11 @@ internal static BashDispatchKind Classify( out IReadOnlyList cwdArgumentElementIndices) { cwdArgumentElementIndices = Array.Empty(); + if (ClassifyExecutionBoundary(clause) == BashExecutionBoundaryKind.Query) + { + return BashDispatchKind.Query; + } + var words = CollectWords(clause); var dispatch = ParseDispatch(clause, words); if (dispatch.IsQuery) @@ -68,6 +84,16 @@ internal static BashDispatchKind Classify( internal static BashExecutionBoundaryKind ClassifyExecutionBoundary(Clause clause) { var words = CollectWords(clause); + if (words.Count > 0) + { + var firstWord = clause.Elements[words[0]]; + if (firstWord.Raw == firstWord.Value && + UnsupportedReservedExecutionSyntax.Contains(firstWord.Value)) + { + return BashExecutionBoundaryKind.UnsupportedReservedExecutionSyntax; + } + } + var dispatch = ParseDispatch(clause, words); if (!dispatch.IsSupported) { @@ -92,6 +118,16 @@ internal static BashExecutionBoundaryKind ClassifyExecutionBoundary(Clause claus return BashExecutionBoundaryKind.ExecutionBearingBuiltin; } + var commandResolutionBoundary = ClassifyCommandResolutionBoundary( + clause, + words, + targetWordIndex, + target.Value); + if (commandResolutionBoundary != BashExecutionBoundaryKind.Allowed) + { + return commandResolutionBoundary; + } + if (target.Value == "printf" && targetWordIndex + 1 < words.Count) { var firstArgument = clause.Elements[words[targetWordIndex + 1]]; @@ -105,6 +141,186 @@ internal static BashExecutionBoundaryKind ClassifyExecutionBoundary(Clause claus return BashExecutionBoundaryKind.Allowed; } + private static BashExecutionBoundaryKind ClassifyCommandResolutionBoundary( + Clause clause, + IReadOnlyList words, + int targetWordIndex, + string target) + { + return target switch + { + "hash" => IsStaticHashQuery(clause, words, targetWordIndex + 1) + ? BashExecutionBoundaryKind.Query + : BashExecutionBoundaryKind.CommandResolutionMutation, + "alias" => IsStaticAliasQuery(clause, words, targetWordIndex + 1) + ? BashExecutionBoundaryKind.Query + : BashExecutionBoundaryKind.CommandResolutionMutation, + "shopt" => IsStaticShoptQuery(clause, words, targetWordIndex + 1) + ? BashExecutionBoundaryKind.Query + : BashExecutionBoundaryKind.CommandResolutionMutation, + "enable" => IsStaticEnableQuery(clause, words, targetWordIndex + 1) + ? BashExecutionBoundaryKind.Query + : BashExecutionBoundaryKind.CommandResolutionMutation, + "unalias" => BashExecutionBoundaryKind.CommandResolutionMutation, + _ => BashExecutionBoundaryKind.Allowed, + }; + } + + private static bool IsStaticHashQuery( + Clause clause, + IReadOnlyList words, + int argumentWordIndex) + { + if (argumentWordIndex == words.Count) + { + return true; + } + + var printLocations = false; + var reusableListing = false; + var operandCount = 0; + for (var index = argumentWordIndex; index < words.Count; index++) + { + var argument = clause.Elements[words[index]]; + if (!IsStaticWord(argument)) + { + return false; + } + + var value = argument.Value; + if (operandCount > 0 && value.Length > 0 && value[0] == '-') + { + return false; + } + + if (operandCount == 0 && value.Length > 1 && value[0] == '-') + { + for (var optionIndex = 1; optionIndex < value.Length; optionIndex++) + { + switch (value[optionIndex]) + { + case 'l': + reusableListing = true; + break; + case 't': + printLocations = true; + break; + default: + return false; + } + } + + continue; + } + + operandCount++; + } + + if (printLocations) + { + return operandCount > 0; + } + + return reusableListing && operandCount == 0; + } + + private static bool IsStaticAliasQuery( + Clause clause, + IReadOnlyList words, + int argumentWordIndex) + { + var allowPrintOption = true; + for (var index = argumentWordIndex; index < words.Count; index++) + { + var argument = clause.Elements[words[index]]; + if (!IsStaticWord(argument)) + { + return false; + } + + if (allowPrintOption && argument.Value == "-p") + { + allowPrintOption = false; + continue; + } + + allowPrintOption = false; + if ((argument.Value.Length > 0 && argument.Value[0] == '-') || + argument.Value.IndexOf('=') >= 0) + { + return false; + } + } + + return true; + } + + private static bool IsStaticShoptQuery( + Clause clause, + IReadOnlyList words, + int argumentWordIndex) + { + var parsingOptions = true; + for (var index = argumentWordIndex; index < words.Count; index++) + { + var argument = clause.Elements[words[index]]; + if (!IsStaticWord(argument)) + { + return false; + } + + var value = argument.Value; + if (parsingOptions && value.Length > 1 && value[0] == '-') + { + for (var optionIndex = 1; optionIndex < value.Length; optionIndex++) + { + if (value[optionIndex] is 's' or 'u' || + value[optionIndex] is not ('p' or 'q' or 'o')) + { + return false; + } + } + + continue; + } + + parsingOptions = false; + if (value.Length > 0 && value[0] == '-') + { + return false; + } + } + + return true; + } + + private static bool IsStaticEnableQuery( + Clause clause, + IReadOnlyList words, + int argumentWordIndex) + { + for (var index = argumentWordIndex; index < words.Count; index++) + { + var argument = clause.Elements[words[index]]; + if (!IsStaticWord(argument) || + argument.Value.Length <= 1 || + argument.Value[0] != '-') + { + return false; + } + + for (var optionIndex = 1; optionIndex < argument.Value.Length; optionIndex++) + { + if (argument.Value[optionIndex] is not ('a' or 'n' or 'p' or 's')) + { + return false; + } + } + } + + return true; + } + private static IReadOnlyList CollectWords(Clause clause) { var words = new List(); @@ -239,5 +455,7 @@ internal enum BashExecutionBoundaryKind Allowed, Query, ExecutionBearingBuiltin, + CommandResolutionMutation, + UnsupportedReservedExecutionSyntax, UnsupportedDispatch, } diff --git a/src/ShellSyntaxTree/Internal/Bash/Parsing/BashStructuralCoordinator.cs b/src/ShellSyntaxTree/Internal/Bash/Parsing/BashStructuralCoordinator.cs index 8ea19b6..fc82d51 100644 --- a/src/ShellSyntaxTree/Internal/Bash/Parsing/BashStructuralCoordinator.cs +++ b/src/ShellSyntaxTree/Internal/Bash/Parsing/BashStructuralCoordinator.cs @@ -571,6 +571,19 @@ private bool TryParseCommand( return false; } + if (executionBoundary == BashExecutionBoundaryKind.CommandResolutionMutation) + { + error = "Bash command-resolution mutation requires structure-aware state analysis"; + return false; + } + + if (executionBoundary == + BashExecutionBoundaryKind.UnsupportedReservedExecutionSyntax) + { + error = "Bash reserved execution syntax requires structural analysis"; + return false; + } + if (executionBoundary == BashExecutionBoundaryKind.UnsupportedDispatch) { error = "Bash command or builtin dispatch grammar is not statically supported"; diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/273_v03_hash_mapping_rejected.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/273_v03_hash_mapping_rejected.json new file mode 100644 index 0000000..4fabd53 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/273_v03_hash_mapping_rejected.json @@ -0,0 +1,10 @@ +{ + "name": "v0.3 Bash hash mapping mutation fails closed", + "input": "hash -p /bin/rm git; git target.txt", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "command-resolution mutation" + }, + "notes": "A hash mapping can replace a later executable identity, so the complete source fails before publishing the apparent git occurrence.", + "oracleExpectation": "OutOfScope" +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/274_v03_alias_activation_rejected.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/274_v03_alias_activation_rejected.json new file mode 100644 index 0000000..38d6fc6 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/274_v03_alias_activation_rejected.json @@ -0,0 +1,10 @@ +{ + "name": "v0.3 Bash alias activation and definition fail closed", + "input": "shopt -s expand_aliases\nalias safe=rm\nsafe target.txt", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "command-resolution mutation" + }, + "notes": "Enabling alias expansion and defining an alias can hide the executable identity of a later newline-delimited command.", + "oracleExpectation": "OutOfScope" +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/275_v03_exec_rejected.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/275_v03_exec_rejected.json new file mode 100644 index 0000000..c6d2a83 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/275_v03_exec_rejected.json @@ -0,0 +1,10 @@ +{ + "name": "v0.3 Bash exec fails at the global execution boundary", + "input": "exec /bin/rm target.txt", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "execution-bearing builtin" + }, + "notes": "Exec replaces the current shell and cannot remain an ordinary complete occurrence outside loops.", + "oracleExpectation": "OutOfScope" +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/276_v03_enable_mutation_rejected.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/276_v03_enable_mutation_rejected.json new file mode 100644 index 0000000..5fa0572 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/276_v03_enable_mutation_rejected.json @@ -0,0 +1,10 @@ +{ + "name": "v0.3 Bash builtin enable mutation fails closed", + "input": "enable -n printf; printf marker", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "command-resolution mutation" + }, + "notes": "Disabling a builtin changes how a later authored command name resolves and therefore fails before the continuation is published.", + "oracleExpectation": "OutOfScope" +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/277_v03_time_prefix_rejected.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/277_v03_time_prefix_rejected.json new file mode 100644 index 0000000..89b1f05 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/277_v03_time_prefix_rejected.json @@ -0,0 +1,10 @@ +{ + "name": "v0.3 Bash time prefix fails closed before nested mutation", + "input": "time hash -p /bin/rm git; git target.txt", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "reserved execution syntax" + }, + "notes": "The Bash time reserved word runs its pipeline in the current shell, so it cannot hide a hash mutation behind an apparent verb chain.", + "oracleExpectation": "OutOfScope" +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/278_v03_negation_prefix_rejected.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/278_v03_negation_prefix_rejected.json new file mode 100644 index 0000000..92c6fd6 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/278_v03_negation_prefix_rejected.json @@ -0,0 +1,10 @@ +{ + "name": "v0.3 Bash negation prefix fails closed before nested mutation", + "input": "! hash -p /bin/rm git; git target.txt", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "reserved execution syntax" + }, + "notes": "Pipeline negation changes status but preserves current-shell mutation, so the nested command must be structurally analyzed before acceptance.", + "oracleExpectation": "OutOfScope" +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/279_v03_brace_group_rejected.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/279_v03_brace_group_rejected.json new file mode 100644 index 0000000..07437e8 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/279_v03_brace_group_rejected.json @@ -0,0 +1,10 @@ +{ + "name": "v0.3 Bash brace group fails closed before nested mutation", + "input": "{ hash -p /bin/rm git; git target.txt; }", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "reserved execution syntax" + }, + "notes": "A brace group executes in the current shell and cannot be flattened into ordinary complete commands before group state propagation is modeled.", + "oracleExpectation": "OutOfScope" +} diff --git a/tests/ShellSyntaxTree.Tests/Parsing/BashExecutionBoundaryTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/BashExecutionBoundaryTests.cs index cd3d5d4..4879f03 100644 --- a/tests/ShellSyntaxTree.Tests/Parsing/BashExecutionBoundaryTests.cs +++ b/tests/ShellSyntaxTree.Tests/Parsing/BashExecutionBoundaryTests.cs @@ -47,6 +47,132 @@ public void Static_dispatch_wrapper_cannot_hide_execution_bearing_builtin(string AssertAtomicFailure(result, "execution-bearing builtin"); } + [Theory] + [InlineData("hash -p /bin/rm git; git target.txt")] + [InlineData("hash -r")] + [InlineData("hash -d git")] + [InlineData("hash git")] + [InlineData("hash -l git")] + [InlineData("hash -t")] + [InlineData("hash -lt")] + [InlineData("hash -t git -p /bin/rm safe")] + [InlineData("hash \"$arguments\"")] + [InlineData("alias safe=rm")] + [InlineData("alias \"$definition\"")] + [InlineData("unalias safe")] + [InlineData("shopt -s expand_aliases")] + [InlineData("shopt -u expand_aliases")] + [InlineData("shopt \"$arguments\"")] + [InlineData("enable printf")] + [InlineData("enable -n printf")] + [InlineData("enable -f /tmp/plugin.so custom")] + [InlineData("enable -d custom")] + [InlineData("enable \"$arguments\"")] + [InlineData("h''ash -p /bin/rm git")] + [InlineData("ha\\sh -r")] + [InlineData("'alias' safe=rm")] + [InlineData("a\"lia\"s safe=rm")] + [InlineData("sho''pt -s expand_aliases")] + [InlineData("en\\able -n printf")] + [InlineData("hash -\"$mode\" git")] + [InlineData("alias safe=\"$command\"")] + [InlineData("shopt -\"$mode\" expand_aliases")] + [InlineData("enable -\"$mode\" printf")] + public void Command_resolution_mutation_or_ambiguous_grammar_fails_atomically( + string source) + { + var result = Parse(source, BashInitialStateMode.IsolatedNonInteractive); + + AssertAtomicFailure(result, "command-resolution mutation"); + } + + [Theory] + [InlineData("command -- hash -p /bin/rm git")] + [InlineData("builtin -- alias safe=rm")] + [InlineData("command -p -- builtin -- shopt -s expand_aliases")] + [InlineData("builtin command -- enable -n printf")] + public void Static_dispatch_wrapper_cannot_hide_command_resolution_mutation( + string source) + { + var result = Parse(source, BashInitialStateMode.IsolatedNonInteractive); + + AssertAtomicFailure(result, "command-resolution mutation"); + } + + [Theory] + [InlineData("exec /bin/rm target.txt")] + [InlineData("exec > output.log")] + [InlineData("command -- exec /bin/printf marker")] + [InlineData("builtin -- exec /bin/printf marker")] + [InlineData("ex''ec /bin/printf marker")] + [InlineData("e\\xec /bin/printf marker")] + public void Exec_fails_at_the_global_execution_boundary(string source) + { + var result = Parse(source, BashInitialStateMode.IsolatedNonInteractive); + + AssertAtomicFailure(result, "execution-bearing builtin"); + } + + [Theory] + [InlineData("time hash -p /bin/rm git; git target.txt")] + [InlineData("time -p command -- hash -p /bin/rm git")] + [InlineData("time -- builtin -- exec /bin/rm target.txt")] + [InlineData("! hash -p /bin/rm git; git target.txt")] + [InlineData("! exec /bin/rm target.txt")] + [InlineData("coproc exec /bin/rm target.txt")] + [InlineData("coproc worker { printf hidden; }")] + [InlineData("{ hash -p /bin/rm git; git target.txt; }")] + [InlineData("{ exec /bin/rm target.txt; }")] + public void Unsupported_reserved_execution_syntax_fails_atomically(string source) + { + var result = Parse(source, BashInitialStateMode.IsolatedNonInteractive); + + AssertAtomicFailure(result, "reserved execution syntax"); + } + + [Theory] + [InlineData("printf '%s' time")] + [InlineData("printf '%s' '!'")] + [InlineData("printf '%s' '{'")] + [InlineData("'time' hash -p /bin/rm git")] + [InlineData("command -- time hash -p /bin/rm git")] + public void Non_reserved_spellings_do_not_invent_reserved_execution_structure( + string source) + { + var result = Parse(source, BashInitialStateMode.IsolatedNonInteractive); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + } + + [Theory] + [InlineData("hash")] + [InlineData("hash -l")] + [InlineData("hash -t git")] + [InlineData("hash -lt git")] + [InlineData("alias")] + [InlineData("alias -p")] + [InlineData("alias safe")] + [InlineData("alias -p safe other")] + [InlineData("shopt")] + [InlineData("shopt -p")] + [InlineData("shopt -q expand_aliases")] + [InlineData("shopt -oq nounset")] + [InlineData("enable")] + [InlineData("enable -a")] + [InlineData("enable -n")] + [InlineData("enable -p")] + [InlineData("enable -s")] + [InlineData("enable -an")] + [InlineData("builtin -- hash -t git")] + [InlineData("command -- shopt -q expand_aliases")] + public void Exact_command_resolution_queries_remain_parseable(string source) + { + var result = Parse(source, BashInitialStateMode.IsolatedNonInteractive); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + Assert.NotEmpty(result.Commands); + } + [Theory] [InlineData("printf \"$option\" data")] [InlineData("printf -v\"$name\" data")] diff --git a/tests/ShellSyntaxTree.Tests/Parsing/ShellValueOracleTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/ShellValueOracleTests.cs index 693288f..8335a3a 100644 --- a/tests/ShellSyntaxTree.Tests/Parsing/ShellValueOracleTests.cs +++ b/tests/ShellSyntaxTree.Tests/Parsing/ShellValueOracleTests.cs @@ -911,6 +911,132 @@ public void Bash_printf_attached_v_option_can_execute_array_subscript_assignment Assert.Contains("PRINTF_V", result.StandardError); } + [Fact] + public void Bash_hash_mapping_replaces_a_later_executable_identity() + { + if (!IsNativeBashAvailable() || !File.Exists("/usr/bin/printf")) + { + return; + } + + var output = Run( + "bash", + "--noprofile", + "--norc", + "-c", + "hash -p /usr/bin/printf safe; safe HASHED"); + + Assert.Equal("HASHED", output); + } + + [Fact] + public void Bash_shopt_and_alias_mutation_replace_a_later_executable_identity() + { + if (!IsNativeBashAvailable()) + { + return; + } + + var output = Run( + "bash", + "--noprofile", + "--norc", + "-c", + "shopt -s expand_aliases\nalias safe='printf ALIASED'\nsafe"); + + Assert.Equal("ALIASED", output); + } + + [Fact] + public void Bash_enable_changes_builtin_command_resolution() + { + if (!IsNativeBashAvailable() || !File.Exists("/usr/bin/printf")) + { + return; + } + + var output = Run( + "bash", + "--noprofile", + "--norc", + "-c", + "enable -n printf; type -t printf; enable printf; type -t printf"); + + Assert.Equal(new[] { "file", "builtin" }, Lines(output)); + } + + [Fact] + public void Bash_exec_replaces_the_current_shell_with_its_target() + { + if (!IsNativeBashAvailable() || !File.Exists("/usr/bin/printf")) + { + return; + } + + var output = Run( + "bash", + "--noprofile", + "--norc", + "-c", + "exec /usr/bin/printf EXECUTED"); + + Assert.Equal("EXECUTED", output); + } + + [Fact] + public void Bash_time_prefix_preserves_current_shell_command_resolution_mutation() + { + if (!IsNativeBashAvailable() || !File.Exists("/usr/bin/printf")) + { + return; + } + + var output = Run( + "bash", + "--noprofile", + "--norc", + "-c", + "TIMEFORMAT=; time hash -p /usr/bin/printf safe; safe TIMED"); + + Assert.Equal("TIMED", output); + } + + [Fact] + public void Bash_negation_prefix_preserves_current_shell_command_resolution_mutation() + { + if (!IsNativeBashAvailable() || !File.Exists("/usr/bin/printf")) + { + return; + } + + var output = Run( + "bash", + "--noprofile", + "--norc", + "-c", + "! hash -p /usr/bin/printf safe; safe NEGATED"); + + Assert.Equal("NEGATED", output); + } + + [Fact] + public void Bash_brace_group_preserves_current_shell_command_resolution_mutation() + { + if (!IsNativeBashAvailable() || !File.Exists("/usr/bin/printf")) + { + return; + } + + var output = Run( + "bash", + "--noprofile", + "--norc", + "-c", + "{ hash -p /usr/bin/printf safe; safe BRACED; }"); + + Assert.Equal("BRACED", output); + } + [Fact] public void Bash_debug_trap_installed_before_a_loop_can_mutate_each_binding() {