Skip to content

Commit f634501

Browse files
Add bounded Bash for-in analysis (#91)
1 parent 6413437 commit f634501

31 files changed

Lines changed: 3721 additions & 108 deletions

IMPLEMENTATION_PLAN.md

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -274,9 +274,21 @@ priorities.
274274
cases pin exact syntax, command ancestry, spans, completeness, and
275275
literal-versus-expanding behavior; real-Bash output and parse-only
276276
oracles independently pin the bounded semantic boundary.
277-
- [ ] Extend Bash substitution discovery to iterables with the complete
278-
`for ... in` vertical slice, then add the Bash substitution cases to the
279-
Netclaw approval matrix.
277+
- [x] Deliver the static-value Bash `for ... in` slice: locked structural
278+
nodes and spans, iterator `$()` discovery, condition-free body
279+
occurrences, exact/finite/pattern/unknown value domains, quote-proved
280+
effective arguments, nested distinct-name correlation, fixed
281+
candidate/depth limits,
282+
strict executable-corpus facts, and real-Bash oracles. Compatibility
283+
leaves preserve authored dynamic operands. Loop binding and cwd
284+
mutation fail closed, loops reached after recognized prior shell-state
285+
mutation fail closed, and occurrence cwd remains Unknown.
286+
- [ ] Design and implement structure-aware Bash abstract-state analysis before
287+
enabling cwd-changing loop bodies or claiming the complete `for ... in`
288+
vertical slice. The parse-order attribution model cannot soundly publish
289+
occurrence cwd across pipelines, conditional lists, substitutions, and
290+
repeated iterations. Keep OpenSpec task 6.5 open, then add the remaining
291+
loop cases and Netclaw approval matrix after that design is reviewed.
280292
- [ ] Complete PowerShell `$()` discovery in `foreach` expressions and add the
281293
Netclaw approval-matrix cases. The simple-command slice is delivered for
282294
ordinary, adjacent, quoted, here-string, redirect, standalone,

SPEC.md

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1070,9 +1070,11 @@ discovered subset.
10701070
The lexer produces tokens consumed by the parser. Token kinds:
10711071

10721072
- **WORD** — sequence of non-whitespace, non-operator, non-quote chars.
1073-
Example: `git`, `/etc/foo`, `--force`, `~/path`, `$VAR`. Simple
1074-
parameter expansion `${VAR}` (no `//` slash) is absorbed into a Word
1075-
token; the resolver in §8 decides `Kind`.
1073+
Example: `git`, `/etc/foo`, `--force`, `~/path`, `$VAR`. A braced
1074+
parameter is absorbed only when its body is a simple shell identifier,
1075+
positional parameter, or special parameter. Parameter operators are
1076+
unparseable because their operands can contain hidden execution; the
1077+
resolver in §8 decides `Kind` for accepted simple forms.
10761078
- **QUOTED_STRING** — single- or double-quoted string. The lexer strips
10771079
the quote delimiters from the token value. Example: `"hello world"`
10781080
becomes the token value `hello world`.
@@ -1096,8 +1098,9 @@ The lexer produces tokens consumed by the parser. Token kinds:
10961098
Expanding-heredoc substitutions use the same opaque fragment semantics but
10971099
remain attached to the delimiter token rather than entering the ordinary
10981100
command-token stream.
1099-
- **UNPARSEABLE_SENTINEL**`$((expr))` arithmetic expansion or
1100-
`${var//pat/repl}` complex parameter expansion. The lexer skips past
1101+
- **UNPARSEABLE_SENTINEL**`$((expr))` arithmetic expansion or any
1102+
operator-bearing parameter expansion such as `${var:-$(cmd)}` or
1103+
`${var//pat/repl}`. The lexer skips past
11011104
the matching close (`))` or `}` respectively) and emits a sentinel
11021105
whose reason names the rejected construct. The parser consumes this
11031106
token by setting outer `ParsedCommand.IsUnparseable = true` (see §11).
@@ -1729,8 +1732,9 @@ Conditions that produce `IsUnparseable = true`:
17291732
- Process substitution (`<(cmd)`, `>(cmd)`).
17301733
- Arithmetic expansion `$((expr))` (per §1 non-goal; lexer emits an
17311734
UNPARSEABLE_SENTINEL token; parser sets the outer flag).
1732-
- Complex parameter expansion `${var//pat/repl}` (per §1 non-goal; same
1733-
mechanism).
1735+
- Operator-bearing parameter expansion such as `${var:-$(cmd)}` or
1736+
`${var//pat/repl}` (per §1 non-goal; same mechanism). Only simple braced
1737+
identifiers, positional parameters, and special parameters are accepted.
17341738
- Recursion depth exceeded on `bash -c` chains (>5 levels).
17351739

17361740
**Diagnostic precedence.** When multiple conditions could fire on a

openspec/changes/v0-3-structured-shell-analysis/design.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,16 @@ scope-isolated groups do not leak state. Branches join their possible exit
422422
states; loops include the zero-iteration path unless shell semantics prove at
423423
least one iteration.
424424

425+
Implementation must run this as a structure-aware abstract-state pass over the
426+
proved syntax tree, not by exposing the compatibility parser's mutable
427+
parse-order cwd attribution. Parse order is not execution-state order for
428+
pipelines or conditional lists, and one symbolic loop-body parse cannot prove
429+
the cwd of later iterations. The compatibility attribution path remains a
430+
v0.2 leaf-construction detail. Until the abstract pass lands, loop cwd mutation
431+
fails closed, recognized shell-state mutation before or inside a loop fails
432+
closed, nested reuse of an active Bash binding name fails closed, and
433+
occurrence `WorkingDirectory` stays `Unknown`.
434+
425435
Bash command substitution executes in an isolated subshell state. State changes
426436
affect later commands inside that substitution but never the containing command
427437
or following outer commands. PowerShell `$()` evaluates in the current runspace

openspec/changes/v0-3-structured-shell-analysis/tasks.md

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -68,11 +68,18 @@
6868

6969
## 6. Bash For-In Vertical Slice
7070

71-
- [ ] 6.1 Parse Bash `for name in literal...; do ...; done` into the locked structural nodes.
72-
- [ ] 6.2 Emit condition-free loop-body occurrences and conservative compatibility clauses.
73-
- [ ] 6.3 Derive exact and finite literal binding domains within the locked candidate cap.
74-
- [ ] 6.4 Substitute a bounded binding only where Bash quoting proves argument boundaries.
71+
- [x] 6.1 Parse Bash `for name in literal...; do ...; done` into the locked structural nodes.
72+
- [x] 6.2 Emit condition-free loop-body occurrences and conservative compatibility clauses.
73+
- [x] 6.3 Derive exact and finite literal binding domains within the locked candidate cap.
74+
- [x] 6.4 Substitute a bounded binding only where Bash quoting proves argument boundaries.
7575
- [ ] 6.5 Propagate and conservatively join cwd and supported binding state across zero-or-more loop execution.
76+
- The first static-value slice deliberately leaves occurrence cwd Unknown
77+
and rejects loop shell-state mutation, nested active-binding reuse, or
78+
loops reached after recognized prior shell-state mutation. A separate
79+
structure-aware abstract-state pass is required
80+
before enabling cwd-changing bodies;
81+
mutable parse-order attribution is unsound across pipelines, `&&` / `||`,
82+
substitutions, and repeated iterations.
7683
- [ ] 6.6 Cover empty iterables, separators, multiline bodies, redirects, pipelines, nested loops, and wrapper boundaries.
7784
- [ ] 6.7 Add adversarial cases for option injection, mutation, unquoted expansion, indirect expansion, substitutions, and cap overflow.
7885
- [ ] 6.8 Add sanitized Bash corpus entries and Netclaw allow/prompt/deny integration cases.

src/ShellSyntaxTree/Internal/Bash/Lexing/BashLexer.cs

Lines changed: 48 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -844,14 +844,9 @@ private static bool TryConsumeComplexParamExpansion(
844844
ReadOnlySpan<char> src, int start, List<BashToken> tokens, out int afterBrace)
845845
{
846846
// src[start] = '$', src[start+1] = '{'. We need to find the matching
847-
// '}' and decide: simple ${VAR} -> false (let word reader take it);
848-
// ${...//...} or any other "complex" form -> emit UnparseableSentinel.
849-
//
850-
// For v0.1 we treat the presence of a slash inside the braces as the
851-
// single signal of "complex param expansion" (per the locked
852-
// interpretation #2 in the OpenSpec change). Other operators inside
853-
// ${...} (like ${X-default}, ${X#prefix}) fall through to the word
854-
// reader; a future PR can tighten this if needed.
847+
// '}' and decide: a simple variable, positional, or special parameter
848+
// falls through to the word reader. Operators can themselves contain
849+
// executable substitutions, so every other body fails closed.
855850
var openBrace = start + 1;
856851
var scan = OpaqueRegionScanner.Scan(src, openBrace, '{', '}');
857852
if (!scan.Closed)
@@ -870,20 +865,9 @@ private static bool TryConsumeComplexParamExpansion(
870865
var endInclusive = scan.EndIndex;
871866
var bodyStart = openBrace + 1;
872867
var bodyEnd = endInclusive; // exclusive of '}'
873-
var hasSlash = false;
874-
for (var k = bodyStart; k < bodyEnd; k++)
868+
var body = src.Slice(bodyStart, bodyEnd - bodyStart);
869+
if (IsSimpleBracedParameterName(body))
875870
{
876-
if (src[k] == '/')
877-
{
878-
hasSlash = true;
879-
break;
880-
}
881-
}
882-
883-
if (!hasSlash)
884-
{
885-
// Simple ${VAR} (or ${X-default} etc.). Caller will fall through
886-
// to the word reader and absorb it as part of a Word token.
887871
afterBrace = -1;
888872
return false;
889873
}
@@ -895,7 +879,7 @@ private static bool TryConsumeComplexParamExpansion(
895879
null,
896880
start,
897881
length,
898-
"complex parameter expansion '${var//pat/repl}' not supported in v0.1"));
882+
"complex parameter expansion is not supported in v0.3"));
899883
afterBrace = start + length;
900884
return true;
901885
}
@@ -1101,9 +1085,9 @@ private static bool TryAppendBashExpansion(
11011085

11021086
expansionLength = scan.EndIndex - start + 1;
11031087
name = src.Slice(start + 2, expansionLength - 3).ToString();
1104-
if (name.Length == 0 || name.IndexOf('/') >= 0)
1088+
if (!IsSimpleBracedParameterName(name.AsSpan()))
11051089
{
1106-
error = "complex parameter expansion '${var//pat/repl}' not supported in v0.1";
1090+
error = "complex parameter expansion is not supported in v0.3";
11071091
index += expansionLength;
11081092
return true;
11091093
}
@@ -1173,6 +1157,46 @@ private static bool IsAllAsciiDigits(string value)
11731157
return true;
11741158
}
11751159

1160+
private static bool IsSimpleBracedParameterName(ReadOnlySpan<char> value)
1161+
{
1162+
if (value.Length == 0)
1163+
{
1164+
return false;
1165+
}
1166+
1167+
if (value.Length == 1 &&
1168+
value[0] is '?' or '$' or '#' or '-' or '!' or '@' or '*')
1169+
{
1170+
return true;
1171+
}
1172+
1173+
var allDigits = true;
1174+
for (var index = 0; index < value.Length; index++)
1175+
{
1176+
allDigits &= value[index] is >= '0' and <= '9';
1177+
}
1178+
1179+
if (allDigits)
1180+
{
1181+
return true;
1182+
}
1183+
1184+
if (!IsBashIdentifierStart(value[0]))
1185+
{
1186+
return false;
1187+
}
1188+
1189+
for (var index = 1; index < value.Length; index++)
1190+
{
1191+
if (!IsBashIdentifierContinuation(value[index]))
1192+
{
1193+
return false;
1194+
}
1195+
}
1196+
1197+
return true;
1198+
}
1199+
11761200
private static bool IsBashIdentifierStart(char value) =>
11771201
value == '_' || value is >= 'A' and <= 'Z' or >= 'a' and <= 'z';
11781202

src/ShellSyntaxTree/Internal/Bash/Parsing/BashCommandParser.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -395,7 +395,8 @@ private static bool TryDetectAnomaly(IReadOnlyList<BashToken> tokens, out string
395395

396396
if (nextIsVerbSlot
397397
&& t.Kind == BashTokenKind.Word
398-
&& BashVerbs.ControlFlowKeywords.Contains(t.Value))
398+
&& BashVerbs.ControlFlowKeywords.Contains(t.Value)
399+
&& t.Value is not ("for" or "do" or "done"))
399400
{
400401
reason = $"control-flow keyword '{t.Value}' is not supported in v0.1";
401402
return true;
@@ -558,6 +559,7 @@ private static ClauseResult ParseClauseSegment(
558559
// Path evidence wins before the lexical verb heuristic.
559560
// The argument pass uses the same classifier.
560561
if (fileVerbCarveout
562+
|| BashVerbs.ControlFlowKeywords.Contains(t.Value)
561563
|| BashResolver.LooksLikePath(t.Value)
562564
|| !BashVerbs.IsVerbLikeToken(t))
563565
{

0 commit comments

Comments
 (0)