Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions IMPLEMENTATION_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,9 +295,14 @@ priorities.
operands, recursive exact `command` / `builtin` dispatch, physical-path
compatibility sanitation, and post-loop binding mutation are pinned by
unit tests, native Bash oracles, the design corpus, and executable corpus.
All unmodeled mutations, dynamic dispatch, and control transfers remain
fail closed. Next add the Netclaw approval matrix before calling the Bash
consumer integration complete.
The coverage matrix now also pins empty and multiline loops, mixed
separators, pipelines, nested loops, wrapper scope, static and
binding-derived redirects, substitutions, option-shaped and unquoted
values, indirect and parameter-operator rejection, and every candidate
and transition cap. All unmodeled mutations, dynamic dispatch, control
transfers, and occurrence-specific redirect values remain fail closed.
Next add the Netclaw approval matrix before calling the Bash consumer
integration complete.
- [ ] Complete PowerShell `$()` discovery in `foreach` expressions and add the
Netclaw approval-matrix cases. The simple-command slice is delivered for
ordinary, adjacent, quoted, here-string, redirect, standalone,
Expand Down
10 changes: 8 additions & 2 deletions openspec/changes/v0-3-structured-shell-analysis/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,14 @@
physical-path sanitation, and persistent post-loop binding protection are
implemented. It still rejects every unmodeled shell-state mutation,
control transfer, nested active-binding reuse, and dynamic dispatch.
- [ ] 6.6 Cover empty iterables, separators, multiline bodies, redirects, pipelines, nested loops, and wrapper boundaries.
- [ ] 6.7 Add adversarial cases for option injection, mutation, unquoted expansion, indirect expansion, substitutions, and cap overflow.
- [x] 6.6 Cover empty iterables, separators, multiline bodies, redirects, pipelines, nested loops, and wrapper boundaries.
- Direct tests, executable corpus, and native Bash oracles cover each
interaction. Redirect-bearing occurrences remain incomplete until the
separately tracked explicit redirect-analysis slice lands.
- [x] 6.7 Add adversarial cases for option injection, mutation, unquoted expansion, indirect expansion, substitutions, and cap overflow.
- The executable corpus includes indirect and parameter-operator rejection,
loop-body substitution, and atomic transition-budget overflow; native
oracles pin the shell semantics behind the conservative boundaries.
- [ ] 6.8 Add sanitized Bash corpus entries and Netclaw allow/prompt/deny integration cases.

## 7. PowerShell Foreach Vertical Slice
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
{
"name": "v0.3 Bash multiline for-in body with redirect",
"input": "for f in a b\ndo\nprintf '%s\\n' \"$f\" > out.txt\necho done\ndone",
"expected": {
"isUnparseable": false,
"clauses": [
{
"operator": "None",
"verb": ["printf"],
"args": [
{ "raw": "'%s\\n'", "kind": "Literal", "isPath": true, "resolved": "/work/%s/n", "isFlag": false },
{ "raw": "\"$f\"", "kind": "EnvVar", "isPath": false, "resolved": "__NULL__", "isFlag": false }
],
"redirects": [
{ "direction": "Out", "target": "/work/out.txt", "isDynamicSkip": false }
]
},
{
"operator": "Sequence",
"verb": ["echo"],
"args": [
{ "raw": "done", "kind": "Literal", "isPath": false, "resolved": "__NULL__", "isFlag": false }
],
"redirects": []
}
],
"syntax": [
{ "kind": "Block", "parentIndex": null, "region": "Unknown", "childIndex": null, "sourceStart": 0, "sourceLength": 59, "clauseIndex": null, "groupKind": null, "listOperator": null },
{ "kind": "ForEach", "parentIndex": 0, "region": "Root", "childIndex": 0, "sourceStart": 0, "sourceLength": 59, "clauseIndex": null, "groupKind": null, "listOperator": null, "bindingName": "f", "bindingRaw": "f", "bindingSourceStart": 4, "bindingSourceLength": 1, "iterableRaw": "a b", "iterableSourceStart": 9, "iterableSourceLength": 3 },
{ "kind": "Block", "parentIndex": 1, "region": "Iterator", "childIndex": null, "sourceStart": 9, "sourceLength": 3, "clauseIndex": null, "groupKind": null, "listOperator": null },
{ "kind": "Block", "parentIndex": 1, "region": "LoopBody", "childIndex": null, "sourceStart": 15, "sourceLength": 40, "clauseIndex": null, "groupKind": null, "listOperator": null },
{ "kind": "CommandList", "parentIndex": 3, "region": "Statement", "childIndex": 0, "sourceStart": 16, "sourceLength": 38, "clauseIndex": null, "groupKind": null, "listOperator": null },
{ "kind": "SimpleCommand", "parentIndex": 4, "region": "Statement", "childIndex": 0, "sourceStart": 16, "sourceLength": 28, "clauseIndex": 0, "groupKind": null, "listOperator": "None" },
{ "kind": "SimpleCommand", "parentIndex": 4, "region": "Statement", "childIndex": 1, "sourceStart": 45, "sourceLength": 9, "clauseIndex": 1, "groupKind": null, "listOperator": "Sequence" }
],
"commands": [
{
"clauseIndex": 0,
"immediateRole": "LoopBody",
"isComplete": false,
"ancestry": [
{ "ancestorKind": "Block", "region": "Root", "childIndex": 0, "sourceStart": 0, "sourceLength": 59 },
{ "ancestorKind": "ForEach", "region": "LoopBody", "childIndex": null, "sourceStart": 0, "sourceLength": 59 },
{ "ancestorKind": "Block", "region": "Statement", "childIndex": 0, "sourceStart": 15, "sourceLength": 40 },
{ "ancestorKind": "CommandList", "region": "Statement", "childIndex": 0, "sourceStart": 16, "sourceLength": 38 }
],
"effectiveArguments": [
{ "clauseElementIndex": 2, "value": { "kind": "FiniteSet", "values": ["a", "b"], "pattern": null, "coveringDirectory": null } }
],
"workingDirectory": { "kind": "Exact", "values": ["/work"], "pattern": null, "coveringDirectory": null }
},
{
"clauseIndex": 1,
"immediateRole": "LoopBody",
"isComplete": true,
"ancestry": [
{ "ancestorKind": "Block", "region": "Root", "childIndex": 0, "sourceStart": 0, "sourceLength": 59 },
{ "ancestorKind": "ForEach", "region": "LoopBody", "childIndex": null, "sourceStart": 0, "sourceLength": 59 },
{ "ancestorKind": "Block", "region": "Statement", "childIndex": 0, "sourceStart": 15, "sourceLength": 40 },
{ "ancestorKind": "CommandList", "region": "Statement", "childIndex": 1, "sourceStart": 16, "sourceLength": 38 }
],
"effectiveArguments": [],
"workingDirectory": { "kind": "Exact", "values": ["/work"], "pattern": null, "coveringDirectory": null }
}
]
},
"notes": "Pins newline list terminators and separators, a multiline loop body, finite loop binding analysis, and the compatibility redirect boundary. Redirect-bearing occurrences remain incomplete until explicit redirect analysis lands."
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
{
"name": "v0.3 Bash loop binding in redirect target",
"input": "for f in a b; do printf '%s' \"$f\" > \"$f.out\"; done",
"expected": {
"isUnparseable": false,
"clauses": [
{
"operator": "None",
"verb": ["printf"],
"args": [
{ "raw": "'%s'", "kind": "Literal", "isPath": false, "resolved": "__NULL__", "isFlag": false },
{ "raw": "\"$f\"", "kind": "EnvVar", "isPath": false, "resolved": "__NULL__", "isFlag": false }
],
"redirects": [
{ "direction": "Out", "target": "\"$f.out\"", "isDynamicSkip": true }
]
}
],
"syntax": [
{ "kind": "Block", "parentIndex": null, "region": "Unknown", "childIndex": null, "sourceStart": 0, "sourceLength": 50, "clauseIndex": null, "groupKind": null, "listOperator": null },
{ "kind": "ForEach", "parentIndex": 0, "region": "Root", "childIndex": 0, "sourceStart": 0, "sourceLength": 50, "clauseIndex": null, "groupKind": null, "listOperator": null, "bindingName": "f", "bindingRaw": "f", "bindingSourceStart": 4, "bindingSourceLength": 1, "iterableRaw": "a b", "iterableSourceStart": 9, "iterableSourceLength": 3 },
{ "kind": "Block", "parentIndex": 1, "region": "Iterator", "childIndex": null, "sourceStart": 9, "sourceLength": 3, "clauseIndex": null, "groupKind": null, "listOperator": null },
{ "kind": "Block", "parentIndex": 1, "region": "LoopBody", "childIndex": null, "sourceStart": 16, "sourceLength": 30, "clauseIndex": null, "groupKind": null, "listOperator": null },
{ "kind": "SimpleCommand", "parentIndex": 3, "region": "Statement", "childIndex": 0, "sourceStart": 17, "sourceLength": 27, "clauseIndex": 0, "groupKind": null, "listOperator": null }
],
"commands": [
{
"clauseIndex": 0,
"immediateRole": "LoopBody",
"isComplete": false,
"ancestry": [
{ "ancestorKind": "Block", "region": "Root", "childIndex": 0, "sourceStart": 0, "sourceLength": 50 },
{ "ancestorKind": "ForEach", "region": "LoopBody", "childIndex": null, "sourceStart": 0, "sourceLength": 50 },
{ "ancestorKind": "Block", "region": "Statement", "childIndex": 0, "sourceStart": 16, "sourceLength": 30 }
],
"effectiveArguments": [
{ "clauseElementIndex": 2, "value": { "kind": "FiniteSet", "values": ["a", "b"], "pattern": null, "coveringDirectory": null } }
],
"workingDirectory": { "kind": "Exact", "values": ["/work"], "pattern": null, "coveringDirectory": null }
}
]
},
"notes": "The argument binding is finite, but the compatibility redirect remains dynamic and the occurrence incomplete until explicit redirect analysis can publish occurrence-specific redirect values."
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "v0.3 Bash for-in indirect expansion rejected",
"input": "for f in name; do echo \"${!f}\"; done",
"expected": {
"isUnparseable": true,
"unparseableReasonContains": "complex parameter expansion is not supported in v0.3"
},
"notes": "Indirect expansion can select ambient variable state, so the bounded loop analyzer rejects the entire executable region rather than guessing an effective value."
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "v0.3 Bash for-in parameter operator rejected",
"input": "for f in value; do echo \"${f:-fallback}\"; done",
"expected": {
"isUnparseable": true,
"unparseableReasonContains": "complex parameter expansion is not supported in v0.3"
},
"notes": "Ordinary parameter operators remain outside the bounded value grammar and fail atomically even when their apparent fallback text is static."
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
{
"name": "v0.3 Bash for-in body substitution inherits binding",
"input": "for f in a b; do printf '%s' \"$(echo \"$f\")\"; done",
"expected": {
"isUnparseable": false,
"clauses": [
{
"operator": "None",
"verb": ["echo"],
"args": [
{ "raw": "\"$f\"", "kind": "EnvVar", "isPath": false, "resolved": "__NULL__", "isFlag": false }
],
"redirects": []
},
{
"operator": "None",
"verb": ["printf"],
"args": [
{ "raw": "'%s'", "kind": "Literal", "isPath": false, "resolved": "__NULL__", "isFlag": false },
{ "raw": "\"$(echo \"$f\")\"", "kind": "DynamicSkip", "isPath": false, "resolved": "__NULL__", "isFlag": false }
],
"redirects": []
}
],
"syntax": [
{ "kind": "Block", "parentIndex": null, "region": "Unknown", "childIndex": null, "sourceStart": 0, "sourceLength": 49, "clauseIndex": null, "groupKind": null, "listOperator": null },
{ "kind": "ForEach", "parentIndex": 0, "region": "Root", "childIndex": 0, "sourceStart": 0, "sourceLength": 49, "clauseIndex": null, "groupKind": null, "listOperator": null, "bindingName": "f", "bindingRaw": "f", "bindingSourceStart": 4, "bindingSourceLength": 1, "iterableRaw": "a b", "iterableSourceStart": 9, "iterableSourceLength": 3 },
{ "kind": "Block", "parentIndex": 1, "region": "Iterator", "childIndex": null, "sourceStart": 9, "sourceLength": 3, "clauseIndex": null, "groupKind": null, "listOperator": null },
{ "kind": "Block", "parentIndex": 1, "region": "LoopBody", "childIndex": null, "sourceStart": 16, "sourceLength": 29, "clauseIndex": null, "groupKind": null, "listOperator": null },
{ "kind": "SimpleCommand", "parentIndex": 3, "region": "Statement", "childIndex": 0, "sourceStart": 17, "sourceLength": 26, "clauseIndex": 1, "groupKind": null, "listOperator": null },
{ "kind": "CommandSubstitution", "parentIndex": 4, "region": "Substitution", "childIndex": 0, "sourceStart": 30, "sourceLength": 12, "clauseIndex": null, "groupKind": null, "listOperator": null },
{ "kind": "Block", "parentIndex": 5, "region": "Substitution", "childIndex": 0, "sourceStart": 32, "sourceLength": 9, "clauseIndex": null, "groupKind": null, "listOperator": null },
{ "kind": "SimpleCommand", "parentIndex": 6, "region": "Statement", "childIndex": 0, "sourceStart": 32, "sourceLength": 9, "clauseIndex": 0, "groupKind": null, "listOperator": null }
],
"commands": [
{
"clauseIndex": 0,
"immediateRole": "Substitution",
"isComplete": true,
"ancestry": [
{ "ancestorKind": "Block", "region": "Root", "childIndex": 0, "sourceStart": 0, "sourceLength": 49 },
{ "ancestorKind": "ForEach", "region": "LoopBody", "childIndex": null, "sourceStart": 0, "sourceLength": 49 },
{ "ancestorKind": "Block", "region": "Statement", "childIndex": 0, "sourceStart": 16, "sourceLength": 29 },
{ "ancestorKind": "CommandSubstitution", "region": "Substitution", "childIndex": 0, "sourceStart": 30, "sourceLength": 12 },
{ "ancestorKind": "Block", "region": "Statement", "childIndex": 0, "sourceStart": 32, "sourceLength": 9 }
],
"effectiveArguments": [
{ "clauseElementIndex": 1, "value": { "kind": "FiniteSet", "values": ["a", "b"], "pattern": null, "coveringDirectory": null } }
],
"workingDirectory": { "kind": "Exact", "values": ["/work"], "pattern": null, "coveringDirectory": null }
},
{
"clauseIndex": 1,
"immediateRole": "LoopBody",
"isComplete": true,
"ancestry": [
{ "ancestorKind": "Block", "region": "Root", "childIndex": 0, "sourceStart": 0, "sourceLength": 49 },
{ "ancestorKind": "ForEach", "region": "LoopBody", "childIndex": null, "sourceStart": 0, "sourceLength": 49 },
{ "ancestorKind": "Block", "region": "Statement", "childIndex": 0, "sourceStart": 16, "sourceLength": 29 }
],
"effectiveArguments": [],
"workingDirectory": { "kind": "Exact", "values": ["/work"], "pattern": null, "coveringDirectory": null }
}
]
},
"notes": "The substitution command is visible in authored order, inherits the finite loop binding, and leaves the outer produced argument policy-sensitive."
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "v0.3 Bash nested for-in transition budget overflow",
"input": "for a in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32; do for b in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32; do for c in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32; do :; done; done; done",
"expected": {
"isUnparseable": true,
"unparseableReasonContains": "Bash structural syntax exceeded limits or contained invalid parser-owned facts"
},
"notes": "The concrete nested iteration product exceeds the 4096-transition analysis budget, so the result fails atomically with no partial projections."
}
58 changes: 58 additions & 0 deletions tests/ShellSyntaxTree.Tests/Parsing/BashForInStructuralTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -752,6 +752,51 @@ public void Pipelines_compose_with_loop_ancestry_without_synthetic_operators()
frame.Region == CommandAncestryRegion.LoopBody);
}

[Fact]
public void Newline_separators_and_redirects_compose_inside_a_multiline_loop_body()
{
const string source = """
for f in a b
do
printf '%s\n' "$f" > out.txt
echo done
done
""";

var result = Parse(source);

Assert.False(result.IsUnparseable, result.UnparseableReason);
Assert.Equal(new[] { CompoundOperator.None, CompoundOperator.Sequence },
result.Clauses.Select(clause => clause.Operator));
var redirect = Assert.Single(result.Clauses[0].Redirects);
Assert.Equal(RedirectDirection.Out, redirect.Direction);
Assert.Equal("/work/out.txt", redirect.Target);
Assert.False(redirect.IsDynamicSkip);
Assert.False(result.Commands[0].IsComplete);
Assert.True(result.Commands[1].IsComplete);
Assert.All(result.Commands, command =>
Assert.Equal(CommandOccurrenceRole.LoopBody, command.ImmediateRole));
AssertDomain(
Assert.Single(result.Commands[0].EffectiveArguments).Value,
ShellValueDomainKind.FiniteSet,
"a",
"b");
}

[Fact]
public void Loop_binding_in_redirect_target_stays_policy_sensitive()
{
var result = Parse(
"for f in a b; do printf '%s' \"$f\" > \"$f.out\"; done");

Assert.False(result.IsUnparseable, result.UnparseableReason);
var command = Assert.Single(result.Commands);
var redirect = Assert.Single(command.Clause.Redirects);
Assert.Equal("\"$f.out\"", redirect.Target);
Assert.True(redirect.IsDynamicSkip);
Assert.False(command.IsComplete);
}

[Theory]
[InlineData("for f in a; do unset f; done")]
[InlineData("for f in a; do read f; done")]
Expand Down Expand Up @@ -826,6 +871,19 @@ public void Hidden_execution_in_parameter_operator_fails_atomically()
Assert.Empty(result.Clauses);
}

[Theory]
[InlineData("for f in name; do echo \"${!f}\"; done")]
[InlineData("for f in value; do echo \"${f:-fallback}\"; done")]
public void Complex_parameter_expansion_in_loop_body_fails_atomically(string source)
{
var result = Parse(source);

Assert.True(result.IsUnparseable);
Assert.Empty(result.Commands);
Assert.Empty(result.Clauses);
Assert.Contains("complex parameter expansion", result.UnparseableReason!);
}

[Fact]
public void Contextual_words_remain_ordinary_arguments_outside_command_position()
{
Expand Down
Loading