Skip to content

Commit 998d835

Browse files
Specify resolver fragment provenance for v0.3 (#75)
1 parent d23fc4d commit 998d835

9 files changed

Lines changed: 629 additions & 20 deletions

File tree

IMPLEMENTATION_PLAN.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,8 +151,20 @@ priorities.
151151
synchronize the accepted shared and PowerShell contracts into
152152
`SPEC.md` / `SPEC.POWERSHELL.md` together with source and snapshot tests
153153
so the repository authority never intentionally drifts from the assembly.
154+
- [ ] Correct the lexer-to-resolver provenance boundary before issue #69.
155+
Paired Bash and PowerShell shell-oracle cases must distinguish escaped
156+
literal resolver syntax from expandable syntax even when both decode to
157+
the same string, including standalone, adjacent-token, all-static
158+
mixed-quote, within-token escape, and literal-plus-expandable cases.
159+
Preserve ordered literal / expandable / opaque fragments internally;
160+
require exact composition when every fragment and resolver fact is
161+
exact, do not change the v0.2 public API, and never infer expansion from
162+
decoded text.
154163
- [ ] Implement [issue #69](https://github.com/Aaronontheweb/ShellSyntaxTree/issues/69)
155-
as the first behavior-preserving preparation after contract lock.
164+
against the corrected fragment contract. Preserve raw, decoded, and span
165+
facts plus unaffected classifications; explicitly document only
166+
shell-oracle-proved compatibility corrections to false path claims and
167+
avoidable `DynamicSkip` results.
156168
- [ ] Add the structural and command-occurrence projections for the existing
157169
grammar before enabling any control-flow construct.
158170
- [ ] Deliver Bash `for ... in` and PowerShell `foreach` as the first two

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

Lines changed: 81 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,64 @@ consumption, error recovery, quoting, and expression boundaries that already
109109
differ between the two shells. Shared components are composed as explicit
110110
classifiers and analysis passes instead.
111111

112+
### Preserve resolver-relevant fragments through decoding
113+
114+
The v0.2 lexer-to-resolver contract is too weak for a security parser. A
115+
decoded `string` plus `IsSingleQuoted` cannot distinguish Bash `\$HOME` from
116+
`$HOME`, or PowerShell `` `$HOME `` from `$HOME`. Both pairs decode to the same
117+
text, but the first value in each pair is literal and the second expands. The
118+
current resolver consequently reports a different filesystem path from the
119+
one the shell passes to the executable.
120+
121+
Both shell front ends SHALL retain resolver-relevant value fragments through
122+
decoding. The internal representation is not public API, but it must preserve
123+
the ordered decoded text, exact source range when available, and one of these
124+
shell-owned dispositions for every fragment:
125+
126+
- `Literal`: quoting or escaping suppresses resolver transformation;
127+
- `Expandable`: the selected shell permits the relevant transformation;
128+
- `Opaque`: the parser cannot prove the produced value.
129+
130+
A representative internal shape is:
131+
132+
```csharp
133+
internal enum ShellValueFragmentKind
134+
{
135+
Literal,
136+
Expandable,
137+
Opaque,
138+
}
139+
140+
internal readonly record struct ShellValueFragment(
141+
string Value,
142+
ShellValueFragmentKind Kind,
143+
int? SourceStart,
144+
int? SourceLength);
145+
146+
internal readonly record struct ShellValue(
147+
string Decoded,
148+
IReadOnlyList<ShellValueFragment> Fragments);
149+
```
150+
151+
This mock is non-normative: an equivalent boundary map or compact segment
152+
representation is acceptable. A single aggregate `IsLiteral` flag is not
153+
acceptable because one authored word can contain both expandable and escaped
154+
regions. The resolver transforms only eligible `Expandable` regions and
155+
preserves `Literal` regions byte-for-byte. When every fragment, supported
156+
transformation, and the required cwd or home fact is exact, it must compose and
157+
resolve the exact result even when literal and expandable fragments are mixed;
158+
it cannot choose `DynamicSkip` merely because retaining provenance requires
159+
more work. `DynamicSkip` is reserved for an `Opaque` fragment, an incomplete
160+
boundary, an unknown required fact, or an unsupported transformation that
161+
prevents an exact path claim. The resolver never infers expansion solely from
162+
the decoded string.
163+
164+
Issue #69's shared native argument classifier consumes these proved fragments
165+
through explicit Bash and PowerShell adapters. It may own adjacency, decoded
166+
concatenation, and source-span aggregation, but the adapters retain shell
167+
quoting and escaping semantics. This correction is internal and leaves the
168+
v0.2 public leaf records and the locked additive v0.3 public API unchanged.
169+
112170
### Represent simple commands with existing Clause leaves
113171

114172
A simple-command syntax node wraps the same `Clause` value exposed through the
@@ -227,14 +285,16 @@ declared completely analyzable.
227285
The implementation order is:
228286

229287
1. Lock public types, compatibility behavior, completeness, and fixed bounds.
230-
2. Extract issue #69 and other behavior-preserving shared helpers.
231-
3. Produce `Syntax`, `Commands`, and unchanged `Clauses` for existing grammar.
232-
4. Validate the new consumer path on existing Netclaw cases.
233-
5. Add Bash `for ... in` with literal values first.
234-
6. Add PowerShell `foreach` with literal arrays next.
235-
7. Extract shared occurrence/value/state machinery proven by both slices.
236-
8. Add bounded patterns and iterator/substitution command discovery.
237-
9. Add condition loops and branches in separately testable shell-specific
288+
2. Correct resolver-fragment provenance with paired real-shell oracles.
289+
3. Extract issue #69 and other shared helpers against the corrected behavior.
290+
4. Produce `Syntax`, `Commands`, and retained compatibility `Clauses` for
291+
existing grammar, including the explicit oracle-proved resolver corrections.
292+
5. Validate the new consumer path on existing Netclaw cases.
293+
6. Add Bash `for ... in` with literal values first.
294+
7. Add PowerShell `foreach` with literal arrays next.
295+
8. Extract shared occurrence/value/state machinery proven by both slices.
296+
9. Add bounded patterns and iterator/substitution command discovery.
297+
10. Add condition loops and branches in separately testable shell-specific
238298
slices.
239299

240300
This order prevents a complete Bash implementation from hardening a
@@ -267,6 +327,9 @@ corpus remains sanitized under the existing PII audit.
267327
- **[Shared abstractions erase language semantics]** -> Keep lexers and
268328
structural parsers separate; extract only duplication demonstrated by both
269329
working slices.
330+
- **[Decoded values erase expansion provenance]** -> Retain ordered internal
331+
literal, expandable, and opaque fragments; never let a resolver reconstruct
332+
those facts from decoded text alone.
270333
- **[Partial trees invite partial authorization]** -> Keep
271334
`IsUnparseable=true`, return empty `Commands` and `Clauses`, and keep any
272335
partial syntax diagnostic-only.
@@ -283,12 +346,17 @@ corpus remains sanitized under the existing PII audit.
283346
2. Accept the OpenSpec and synchronize the locked API and grammar into
284347
`SPEC.md`, `SPEC.POWERSHELL.md`, `PROJECT_CONTEXT.md`, and
285348
`IMPLEMENTATION_PLAN.md`.
286-
3. Ship the new structural and occurrence API in a 0.3.0 alpha while all
287-
existing grammar produces byte-for-byte equivalent compatibility facts.
288-
4. Migrate Netclaw to `Commands` and explicit redirect facts before enabling
349+
3. Correct the internal resolver-fragment contract and promote each paired
350+
design case into the executable corpus before extracting issue #69.
351+
4. Ship the new structural and occurrence API in a 0.3.0 alpha while existing
352+
grammar preserves raw spelling, decoded logical values, source spans, and
353+
unaffected compatibility facts. Paired shell-oracle corrections to false
354+
path or `DynamicSkip` claims are explicit compatibility notes, not hidden
355+
behavior-preserving changes.
356+
5. Migrate Netclaw to `Commands` and explicit redirect facts before enabling
289357
supported control flow for authorization reuse.
290-
5. Add Bash and PowerShell vertical slices behind corpus and integration gates.
291-
6. Promote 0.3.0 only after both shells, old-consumer fail-closed behavior, and
358+
6. Add Bash and PowerShell vertical slices behind corpus and integration gates.
359+
7. Promote 0.3.0 only after both shells, old-consumer fail-closed behavior, and
292360
the new Netclaw consumer path pass their acceptance matrices.
293361

294362
Before stable 0.3.0, a flawed new surface can be revised with prerelease

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ fail-closed behavior for incomplete analysis.
2222
- Add conservative value and shell-state analysis that distinguishes exact,
2323
finite, bounded-symbolic, and unknown facts without executing commands or
2424
enumerating the filesystem.
25+
- Preserve resolver-relevant lexical fragments through decoding so escaped or
26+
quoted literal syntax cannot be mistaken for expandable syntax with the same
27+
decoded text.
2528
- Add explicit redirect operation and target facts so consumers do not infer
2629
descriptor duplication, close, move, combined output, or dynamic targets
2730
from raw strings and `DynamicSkip` alone.
@@ -73,6 +76,12 @@ PowerShell specifications, and `docs/CONSUMER_GUIDE.md`. Adding properties to
7376
public records also changes generated equality, hashing, `ToString()`, and
7477
default serialization and therefore requires explicit migration notes.
7578

79+
The implementation also corrects a v0.2 security defect at an internal
80+
boundary: decoded token text currently loses whether resolver-sensitive bytes
81+
were literal or expandable. The correction fixes false path claims and
82+
avoidable `DynamicSkip` results while retaining the shipped public API, raw
83+
spelling, decoded logical values, and source spans.
84+
7685
Netclaw is the validating consumer. Its 0.25.4 redirect workaround remains the
7786
short-term containment; a v0.3 integration must switch authorization traversal
7887
to the command-occurrence projection and retain strict or prompt behavior for

openspec/changes/v0-3-structured-shell-analysis/specs/bounded-shell-analysis/spec.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,66 @@
11
## ADDED Requirements
22

3+
### Requirement: Decoded values retain resolver provenance
4+
The parser SHALL retain enough shell-specific lexical provenance to distinguish
5+
resolver-sensitive literal, expandable, and opaque fragments after escape and
6+
quote decoding. It SHALL NOT infer whether text expands solely from the
7+
decoded string. Literal fragments SHALL remain literal and eligible expandable
8+
fragments SHALL be transformed under the existing bounded rules. When every
9+
fragment, supported transformation, and required cwd or home fact is exact,
10+
the parser SHALL compose the exact shell value and SHALL return the exact
11+
compatibility path result for a path position. It SHALL NOT return `DynamicSkip`
12+
solely because one value contains both literal and expandable fragments. An
13+
opaque or incompletely mapped fragment, unknown required fact, or unsupported
14+
transformation SHALL produce an unknown or `DynamicSkip` fact rather than a
15+
false path.
16+
17+
The provenance representation is internal and SHALL NOT change the v0.2 public
18+
leaf API. Raw spelling, decoded logical values, and exact-or-null source spans
19+
retain their existing meanings.
20+
21+
#### Scenario: Bash escaped variable is a literal path component
22+
- **WHEN** Bash parses `cat \$HOME` with an exact working directory
23+
- **THEN** the shell value is the literal `$HOME`
24+
- **THEN** the compatibility argument is a literal path resolved as `<cwd>/$HOME`
25+
- **THEN** it is not `DynamicSkip` and is not resolved as the configured home directory
26+
27+
#### Scenario: PowerShell escaped variable is a literal path component
28+
- **WHEN** PowerShell parses ``Get-Content `$HOME`` with an exact working directory
29+
- **THEN** the shell value is the literal `$HOME`
30+
- **THEN** the compatibility argument is a literal path resolved as `<cwd>/$HOME`
31+
- **THEN** it is not `DynamicSkip` and is not resolved as the configured home directory
32+
33+
#### Scenario: Escaped prefix composes with an adjacent quoted suffix
34+
- **WHEN** either shell parses its escaped-dollar spelling of `curl --data=@$HOME".json" URL`
35+
- **THEN** the complete native argument retains exact raw and decoded provenance
36+
- **THEN** curl's compatibility file operand is the literal path `<cwd>/$HOME.json`
37+
- **THEN** it is not `DynamicSkip` and is not a path under the configured home directory
38+
39+
#### Scenario: All-static mixed quoting remains exact
40+
- **WHEN** either shell parses `curl --data='@$HOME'".json" URL`
41+
- **THEN** both adjacent fragments retain literal provenance
42+
- **THEN** the compatibility file operand is the literal path `<cwd>/$HOME.json`
43+
- **THEN** it is not `DynamicSkip`
44+
45+
#### Scenario: Escape provenance survives inside one quoted token
46+
- **WHEN** either shell parses its escaped-dollar spelling of a quoted `$HOME.txt` path
47+
- **THEN** the literal dollar and the rest of the token compose exactly
48+
- **THEN** the compatibility argument resolves as `<cwd>/$HOME.txt`, not under the configured home directory
49+
50+
#### Scenario: Literal and expandable regions compose inside one token
51+
- **WHEN** either shell parses its spelling of a quoted literal `${HOME}` followed by an expandable `$HOME`
52+
- **THEN** the first region remains literal and the second uses the configured home fact
53+
- **THEN** the exact composed shell value is retained rather than becoming `DynamicSkip`
54+
55+
#### Scenario: Expandable variable remains expandable
56+
- **WHEN** either shell parses an unescaped expandable `$HOME` in a supported path position
57+
- **THEN** the resolver may use the configured home-directory fact
58+
- **THEN** the escaped and expandable spellings do not collapse to the same provenance
59+
60+
#### Scenario: Opaque fragment remains fail closed
61+
- **WHEN** an adjacent native argument contains a command substitution, subexpression, splat, or another opaque fragment
62+
- **THEN** the parser does not synthesize an exact path from the remaining decoded text
63+
364
### Requirement: Shell values use explicit proof domains
465
The analysis SHALL classify a policy-relevant shell value as exact, finite,
566
bounded symbolic pattern, or unknown, and SHALL NOT present a weaker proof as a

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

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,14 @@
1111
- [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.
1212
- [ ] 1.10 Promote each design case into the executable corpus as its production parser slice lands.
1313

14-
## 2. Behavior-Preserving Shared Preparation
15-
16-
- [ ] 2.1 Implement issue #69's shell-neutral native argument-fragment classifier with explicit Bash and PowerShell adapters.
17-
- [ ] 2.2 Prove all existing raw, decoded, span, path, and `DynamicSkip` results remain unchanged in both corpora.
18-
- [ ] 2.3 Audit duplicated Bash and PowerShell path-normalization helpers and extract only rules with identical shell semantics.
19-
- [ ] 2.4 Run Release build, full tests, and header verification for the behavior-preserving refactor.
14+
## 2. Resolver Provenance Correction and Shared Preparation
15+
16+
- [ ] 2.1 Implement shell-specific lexical fragment provenance that distinguishes literal, expandable, and opaque resolver input without changing the public API.
17+
- [ ] 2.2 Add paired Bash and PowerShell shell-oracle regressions for standalone escapes, adjacent escaped values, all-static mixed quoting, within-token escapes, and genuine literal-plus-expandable values; require exact compatibility path results when every fragment and required resolver fact is exact, otherwise fail closed.
18+
- [ ] 2.3 Implement issue #69's shell-neutral native argument-fragment classifier with explicit Bash and PowerShell adapters that preserve the new provenance.
19+
- [ ] 2.4 Prove raw spelling, decoded logical values, source spans, and unaffected classifications remain unchanged; document each oracle-proved path or `DynamicSkip` compatibility correction.
20+
- [ ] 2.5 Audit duplicated Bash and PowerShell path-normalization helpers and extract only rules with identical shell semantics.
21+
- [ ] 2.6 Run Release build, full tests, header verification, and the adversarial security corpus for the completed preparation.
2022

2123
## 3. Structural and Projection Skeleton
2224

tests/ShellSyntaxTree.Tests/DesignCorpus/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,9 @@ publishing a truncated finite set. Supported heredocs and Bash here strings
3636
record data separately from executable substitutions and path-relevant
3737
redirects. Constructs deliberately deferred beyond stable v0.3 remain in the
3838
corpus as unparseable security boundaries.
39+
40+
Resolver-provenance cases additionally pin selected current `Arg` fields. They
41+
record the v0.2 false path claim until its production slice lands, while the
42+
desired effective value records the literal shell value proved by the paired
43+
real-shell oracle. Those cases then move into the executable corpus instead of
44+
being treated as behavior that issue #69 must preserve.

tests/ShellSyntaxTree.Tests/DesignCorpus/V03DesignCorpusTests.cs

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,18 @@ public void Current_expectations_match_the_v0_2_parsers()
7979
actual.UnparseableReason ?? string.Empty,
8080
StringComparison.OrdinalIgnoreCase);
8181
}
82+
83+
if (designCase.Current.Argument is not null)
84+
{
85+
var expected = designCase.Current.Argument;
86+
ValidateArgumentExpectation(designCase.Id, expected, actual.Clauses.Count);
87+
var clause = Assert.IsType<Clause>(actual.Clauses[expected.ClauseIndex]);
88+
var argument = Assert.IsType<Arg>(clause.Args[expected.ArgumentIndex]);
89+
Assert.Equal(expected.Raw, argument.Raw);
90+
Assert.Equal(expected.Kind, argument.Kind);
91+
Assert.Equal(expected.IsPath, argument.IsPath);
92+
Assert.Equal(expected.Resolved, argument.Resolved);
93+
}
8294
}
8395
}
8496
}
@@ -177,6 +189,27 @@ or DesignRedirectOperation.CombinedOutput
177189
Assert.Equal(
178190
desired.Commands.Select(command => command.AuthoredVerb),
179191
desired.Compatibility.Verbs);
192+
193+
if (desired.Argument is not null)
194+
{
195+
ValidateArgumentExpectation(
196+
designCase.Id,
197+
desired.Argument,
198+
desired.Compatibility.Verbs.Count);
199+
}
200+
}
201+
202+
private static void ValidateArgumentExpectation(
203+
string caseId, ArgumentExpectation argument, int clauseCount)
204+
{
205+
Assert.InRange(argument.ClauseIndex, 0, clauseCount - 1);
206+
Assert.True(argument.ArgumentIndex >= 0, $"{caseId}: argument index must be non-negative.");
207+
Assert.False(string.IsNullOrWhiteSpace(argument.Raw));
208+
if (argument.Resolved is not null)
209+
{
210+
Assert.True(argument.IsPath, $"{caseId}: a resolved argument must be path-relevant.");
211+
Assert.NotEqual(ArgKind.DynamicSkip, argument.Kind);
212+
}
180213
}
181214

182215
private static void ValidateValue(
@@ -266,6 +299,23 @@ public sealed record CurrentBehaviorExpectation
266299
public bool IsUnparseable { get; init; }
267300

268301
public string? ReasonContains { get; init; }
302+
303+
public ArgumentExpectation? Argument { get; init; }
304+
}
305+
306+
public sealed record ArgumentExpectation
307+
{
308+
public int ClauseIndex { get; init; }
309+
310+
public int ArgumentIndex { get; init; }
311+
312+
public string Raw { get; init; } = "";
313+
314+
public ArgKind Kind { get; init; }
315+
316+
public bool IsPath { get; init; }
317+
318+
public string? Resolved { get; init; }
269319
}
270320

271321
public sealed record DesiredDesignExpectation
@@ -276,6 +326,8 @@ public sealed record DesiredDesignExpectation
276326

277327
public IReadOnlyList<DesignCommandExpectation> Commands { get; init; } = [];
278328

329+
public ArgumentExpectation? Argument { get; init; }
330+
279331
public CompatibilityExpectation Compatibility { get; init; } = new();
280332

281333
public IReadOnlyList<SecurityInvariant> SecurityInvariants { get; init; } = [];
@@ -453,4 +505,5 @@ public enum SecurityInvariant
453505
ContextualKeywordNotControlFlow,
454506
ShellSpecificOptionSemantics,
455507
StaticRedirectNotDynamic,
508+
LiteralExpansionProvenancePreserved,
456509
}

0 commit comments

Comments
 (0)