Skip to content

Commit 3c1b896

Browse files
committed
Add PowerShell initial state contract
1 parent 507da91 commit 3c1b896

17 files changed

Lines changed: 621 additions & 42 deletions

File tree

IMPLEMENTATION_PLAN.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -315,10 +315,15 @@ priorities.
315315
initial-runspace contract and wrapper-state metadata: ambient typed,
316316
read-only, scoped, alias, function, and module state can change binding
317317
assignment and command resolution, while child hosts inherit no fresh
318-
state guarantee unless their own invocation proves it. Expand the design
319-
corpus for cardinality, mutation, separators, wrappers, redirects, and
320-
transition caps before tasks 7.3-7.7. The simple-command slice is delivered for
321-
ordinary, adjacent, quoted, here-string, redirect, standalone,
318+
state guarantee unless their own invocation proves it. The additive
319+
`PwshInitialStateMode` API and safe-default contract are now locked;
320+
`-NoProfile -NonInteractive` alone is explicitly insufficient without a
321+
controlled startup, inherited environment, and module baseline. Design
322+
cases select the mode individually and pin default `Unknown`. Tasks
323+
7.3-7.4 must consume the contract rather than inferring isolation. Expand the
324+
design corpus for cardinality, mutation, separators, wrappers, redirects,
325+
and transition caps before tasks 7.3-7.7. The simple-command slice is
326+
delivered for ordinary, adjacent, quoted, here-string, redirect, standalone,
322327
call-operator, dynamic-identity, and host-wrapper positions, with
323328
current-scope state propagation and bounded expression rejection pinned
324329
by the 361-entry executable corpus.

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,8 +117,8 @@ public sealed class BashParser : IShellParser { /* … */ }
117117
public sealed class PwshParser : IShellParser { /**/ } // v0.2.0
118118
119119
public abstract record ShellParserOptions { /* HomeDirectory, WorkingDirectory */ }
120-
public sealed record BashParserOptions : ShellParserOptions;
121-
public sealed record PwshParserOptions : ShellParserOptions;
120+
public sealed record BashParserOptions : ShellParserOptions; // InitialStateMode
121+
public sealed record PwshParserOptions : ShellParserOptions; // InitialStateMode
122122
123123
public sealed record ParsedCommand { /* Source, Clauses, IsUnparseable, … */ }
124124
public sealed record Clause { /* Operator, Verb, Args, Redirects, Elements, IsSubshell, IsCommandStringWrapped */ }

SPEC.POWERSHELL.md

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -115,11 +115,18 @@ public abstract record ShellParserOptions
115115
/// = ... }` still compiles.</summary>
116116
public sealed record BashParserOptions : ShellParserOptions;
117117

118-
/// <summary>Configuration knobs for PwshParser. Empty in v0.2.0 — alias
119-
/// resolution is unconditional (§6.3) and the resolver knobs live on the
120-
/// shared ShellParserOptions base. Kept as a distinct type so a future
121-
/// PowerShell-only knob is an additive change, not a new type.</summary>
122-
public sealed record PwshParserOptions : ShellParserOptions;
118+
/// <summary>Declares which ambient PowerShell runspace facts the caller can prove.</summary>
119+
public enum PwshInitialStateMode
120+
{
121+
Unknown,
122+
IsolatedNonInteractiveNoProfile,
123+
}
124+
125+
/// <summary>Configuration knobs for PwshParser.</summary>
126+
public sealed record PwshParserOptions : ShellParserOptions
127+
{
128+
public PwshInitialStateMode InitialStateMode { get; init; }
129+
}
123130

124131
/// <summary>PowerShell implementation of IShellParser.</summary>
125132
public sealed class PwshParser : IShellParser
@@ -407,6 +414,39 @@ after the structural grammar proves that the `ScriptBlock` token is the body
407414
of a recognized statement. An ordinary script-block argument remains one
408415
opaque `DynamicSkip` value and does not invent child execution.
409416

417+
Publishing those exact or finite values also requires
418+
`PwshInitialStateMode.IsolatedNonInteractiveNoProfile`. The default `Unknown`
419+
mode still exposes the complete supported structure, but loop-body occurrences
420+
whose safety depends on the binding remain incomplete. The isolated mode is a
421+
caller assertion that the complete source runs in a newly spawned,
422+
non-interactive, no-profile PowerShell process with no reused or
423+
uncontrolled caller-initialized runspace state. Startup configuration and the
424+
inherited environment must also be controlled: module auto-loading is disabled,
425+
or available modules and module search paths are pinned to the same reviewed
426+
baseline used by policy. `-NoProfile -NonInteractive` alone is insufficient.
427+
A fixed bootstrap may establish those constraints only when it cannot define
428+
or mutate loop-bound variables or policy-relevant command identities. The mode
429+
does not permit assumptions about an interactive session, a runspace pool,
430+
profiles, startup scripts, or uncontrolled ambient variables, aliases,
431+
functions, and modules.
432+
433+
Even under that assertion, only ordinary unscoped binding names that do not
434+
case-insensitively collide with PowerShell's automatic, constant, or read-only
435+
variables are eligible. Scoped/provider bindings such as `$global:x`,
436+
`$script:x`, `$private:x`, and `$env:X` fail the loop region closed. A typed,
437+
validated, constant, or read-only ambient binding therefore cannot coerce,
438+
reject, or otherwise alter a value the analyzer presents as an exact string.
439+
440+
Parenthesized groups, `$()`, and static `Invoke-Expression` execute in the
441+
current runspace and share supported binding, command-resolution, and location
442+
state. Decoded `pwsh -Command` and `pwsh -EncodedCommand` payloads run in child
443+
hosts and do not inherit the parent's fresh-state assertion unless their own
444+
invocation independently proves the complete constrained-host contract; host
445+
flags alone do not prove the launch environment or module baseline.
446+
Recognized variable, alias, function, or module mutation invalidates later
447+
proofs in every observing scope; cwd-only mutation retains the independent
448+
initial-state assertion.
449+
410450
A completely delimited `$()` used as an ordinary word, dynamic command
411451
identity after `&`, redirect value, foreach expression, double-quoted interpolation, or
412452
expandable here-string is recursively parsed as a command substitution. Its

SPEC.md

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -122,9 +122,18 @@ public sealed record BashParserOptions : ShellParserOptions
122122
public BashInitialStateMode InitialStateMode { get; init; }
123123
}
124124

125-
/// <summary>Configuration knobs for PwshParser (v0.2.0). Empty — the
126-
/// resolver knobs live on ShellParserOptions.</summary>
127-
public sealed record PwshParserOptions : ShellParserOptions;
125+
/// <summary>Declares which ambient PowerShell runspace facts the caller can prove.</summary>
126+
public enum PwshInitialStateMode
127+
{
128+
Unknown,
129+
IsolatedNonInteractiveNoProfile,
130+
}
131+
132+
/// <summary>Configuration knobs for PwshParser.</summary>
133+
public sealed record PwshParserOptions : ShellParserOptions
134+
{
135+
public PwshInitialStateMode InitialStateMode { get; init; }
136+
}
128137

129138
// The pre-v0.2.0 BashParserOptions body, now hoisted onto ShellParserOptions:
130139
public abstract record ShellParserOptions
@@ -270,6 +279,42 @@ as `RANDOM`, `LINENO`, `HOME`, `PATH`, `CDPATH`, and `IFS`. The boundary is
270279
extend-only: a later version may add a proved variable-state model or
271280
additional explicitly reviewed ordinary names.
272281

282+
`PwshInitialStateMode.Unknown` is likewise the safe default. In this mode the
283+
parser may expose `foreach` structure and commands, but it does not publish an
284+
exact or finite loop-binding proof. Ambient PowerShell variables can be typed,
285+
read-only, constant, scoped, or validated, and ambient aliases, functions, and
286+
modules can change command resolution. Treating a loop assignment as a plain
287+
string assignment without excluding those facts would be unsound.
288+
289+
`PwshInitialStateMode.IsolatedNonInteractiveNoProfile` is an explicit caller
290+
assertion that the complete source is executed by a newly spawned,
291+
non-interactive PowerShell process with profiles disabled and without a reused
292+
or uncontrolled caller-initialized runspace. The caller must also control
293+
startup configuration and the inherited environment: module auto-loading must
294+
be disabled, or the available modules and module search paths must be pinned to
295+
the same reviewed baseline used by policy. `-NoProfile -NonInteractive` alone
296+
does not establish this contract. A fixed bootstrap may establish these
297+
constraints only when it cannot define or mutate loop-bound variables or
298+
policy-relevant command identities.
299+
300+
The mode does not erase PowerShell's built-in automatic variables. Exact and
301+
finite binding proofs remain limited to
302+
ordinary unscoped variable names that do not collide, case-insensitively, with
303+
automatic, constant, or read-only bindings known to the supported PowerShell
304+
runtime. Scoped/provider forms such as `$global:x`, `$script:x`, and `$env:X`
305+
are outside the bounded loop-binding grammar.
306+
307+
The assertion applies only to the host that the caller actually constrains.
308+
Current-runspace regions such as `( ... )`, `$()`, and a static
309+
`Invoke-Expression` payload share supported variable, command-resolution, and
310+
location state. A decoded `pwsh -Command` or `pwsh -EncodedCommand` child does
311+
not inherit the assertion unless its own invocation contract independently
312+
proves the complete constrained-host environment, not merely `-NoProfile`.
313+
Recognized mutation of variables,
314+
aliases, functions, or modules invalidates later proofs wherever PowerShell
315+
scope rules make the mutation observable. Cwd-only state changes retain the
316+
independent initial-runspace assertion.
317+
273318
For a successful result, every authored simple command appears once in
274319
`Syntax`, once in `Commands`, and once in `Clauses`, with all three projections
275320
referencing the identical in-memory `Clause` instance. Serialization is not

docs/CONSUMER_GUIDE.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,34 @@ outside the first bounded scalar grammar. The parser also downgrades a decoded
198198
`export`; resolver-only option cloning for an exact cwd retains the independent
199199
variable-state assertion.
200200

201+
PowerShell `foreach` value proofs require the parallel but shell-specific
202+
assertion. `PwshInitialStateMode.Unknown` is the safe default: the parser can
203+
still expose supported loop structure, but ambient typed, validated,
204+
read-only, scoped, alias, function, and module state prevents a closed-world
205+
binding proof. Select `IsolatedNonInteractiveNoProfile` only when the caller
206+
executes the complete source in a newly spawned noninteractive PowerShell
207+
process with profiles disabled and no reused or uncontrolled caller-initialized
208+
runspace. The launch must also disable module auto-loading or pin available
209+
modules and module search paths to the same reviewed baseline used by policy.
210+
A fixed bootstrap may establish those constraints only if it cannot define or
211+
mutate loop-bound variables or policy-relevant command identities:
212+
213+
```csharp
214+
var parser = new PwshParser(new PwshParserOptions
215+
{
216+
WorkingDirectory = workingDirectory,
217+
InitialStateMode = PwshInitialStateMode.IsolatedNonInteractiveNoProfile,
218+
});
219+
```
220+
221+
The assertion does not automatically cross `pwsh -Command` or
222+
`pwsh -EncodedCommand`; a child host needs its own independently proved launch
223+
contract. By contrast, `( ... )`, `$()`, and static `Invoke-Expression` share
224+
the current runspace and its mutations. Never select isolated mode for an
225+
interactive session or runspace pool merely to suppress approval prompts.
226+
`-NoProfile -NonInteractive` alone does not prove the inherited environment,
227+
startup configuration, or module baseline.
228+
201229
Heredoc and Bash here-string bodies are stdin data, not implicit child commands
202230
or filesystem paths. Authorize any command substitutions surfaced from an
203231
expanding heredoc as normal occurrences, then let executable-specific policy

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,32 @@ later scope that can observe it. A decoded `bash -c` after `export` therefore
423423
enters with `Unknown` initial variable state, while a cwd-only transfer retains
424424
the caller's variable-state assertion.
425425

426+
PowerShell needs the same explicit boundary for different reasons.
427+
`PwshParserOptions.InitialStateMode` defaults to `Unknown`, which permits
428+
structural discovery but withholds exact or finite `foreach` binding proofs.
429+
`IsolatedNonInteractiveNoProfile` asserts that the complete source executes in
430+
a newly spawned noninteractive, no-profile PowerShell process rather than an
431+
interactive, pooled, reused, profile-initialized, or uncontrolled caller-initialized
432+
runspace. The caller also controls startup configuration and the inherited
433+
environment: module auto-loading is disabled, or available modules and module
434+
search paths are pinned to the policy's reviewed baseline. A fixed bootstrap
435+
may establish those constraints only when it cannot define or mutate
436+
loop-bound variables or policy-relevant command identities.
437+
`-NoProfile -NonInteractive` alone is not proof of that environment. Ambient
438+
PowerShell bindings can be typed, validated, constant,
439+
read-only, or scoped; aliases, functions, and modules can independently change
440+
command identity. Syntax alone cannot erase any of those facts.
441+
442+
The positive binding grammar therefore accepts only ordinary unscoped names
443+
that do not case-insensitively collide with automatic, constant, or read-only
444+
variables known to the supported runtime. Current-runspace groups, `$()`, and
445+
static `Invoke-Expression` share supported binding, command-resolution, and cwd
446+
state. A decoded child `pwsh` host starts at `Unknown` unless its own invocation
447+
independently proves the complete constrained-host contract. Recognized
448+
variable, alias,
449+
function, or module mutation invalidates every later observing proof; a cwd-only
450+
transfer preserves the independent initial-runspace assertion.
451+
426452
Effective values are shell facts, not executable semantics. The analysis must
427453
preserve both the authored shell classification and each proved effective
428454
value. PowerShell does not retroactively turn a string value such as `-Force`

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

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,58 @@ the independently proved variable-state mode.
168168
- **THEN** the decoded child enters with unknown initial variable state
169169
- **THEN** the complete result is unparseable rather than publishing an isolated scalar proof
170170

171+
### Requirement: PowerShell loop proofs require an explicit initial-runspace contract
172+
`PwshParserOptions.InitialStateMode` SHALL default to `Unknown`. In that mode,
173+
the parser MAY expose supported `foreach` structure and command occurrences,
174+
but SHALL NOT publish an exact or finite loop-binding proof whose semantics
175+
could be changed by ambient runspace state.
176+
177+
`IsolatedNonInteractiveNoProfile` SHALL be an explicit caller assertion that
178+
the complete source runs in a newly spawned noninteractive PowerShell process,
179+
profiles are disabled, and the runspace has not been reused or initialized by
180+
uncontrolled caller variables, aliases, functions, or modules. The caller SHALL
181+
also control startup configuration and the inherited environment. Module
182+
auto-loading SHALL be disabled, or available modules and module search paths
183+
SHALL be pinned to the same reviewed baseline used by policy. A fixed bootstrap
184+
MAY establish those constraints only when it cannot define or mutate loop-bound
185+
variables or policy-relevant command identities. `-NoProfile -NonInteractive`
186+
alone SHALL NOT satisfy the contract. Exact and finite
187+
binding analysis SHALL remain limited to ordinary unscoped names that do not
188+
case-insensitively collide with automatic, constant, or read-only variables.
189+
Scoped/provider binding forms SHALL fail closed.
190+
191+
Current-runspace groups, `$()`, and static `Invoke-Expression` payloads SHALL
192+
share supported binding, command-resolution, and cwd state. A decoded child
193+
PowerShell host SHALL NOT inherit the parent's fresh-state assertion unless
194+
that invocation independently proves the complete constrained-host contract.
195+
Host flags alone SHALL NOT prove the launch environment or module baseline.
196+
Recognized variable, alias, function, or module mutation SHALL invalidate later proofs in
197+
every observing scope; cwd-only mutation SHALL retain the independent
198+
initial-state assertion.
199+
200+
#### Scenario: Unknown ambient PowerShell state withholds a finite proof
201+
- **WHEN** default-mode PowerShell parses `foreach ($f in @('a','b')) { Remove-Item -LiteralPath $f }`
202+
- **THEN** the loop structure and body command may remain visible
203+
- **THEN** the body occurrence is incomplete rather than assuming `$f` is an ordinary string binding
204+
205+
#### Scenario: Isolated no-profile runspace permits an ordinary binding proof
206+
- **WHEN** the caller selects `IsolatedNonInteractiveNoProfile` for a newly spawned constrained host and parses `foreach ($f in @('a','b')) { Write-Output $f }`
207+
- **THEN** the bounded analyzer may publish the finite string domain `a`, `b`
208+
209+
#### Scenario: Typed or read-only ambient binding is not erased by syntax
210+
- **WHEN** a reused runspace already contains `[int]$f` or a read-only `$f` and parses a loop that assigns string values
211+
- **THEN** default-mode analysis does not claim the authored strings are the effective loop values
212+
- **THEN** selecting isolated mode for that reused runspace would violate the caller contract
213+
214+
#### Scenario: Child host does not inherit the parent's assertion
215+
- **WHEN** isolated-mode PowerShell parses a supported `pwsh -NoProfile -Command` child containing a `foreach`
216+
- **THEN** the child receives `Unknown` initial state unless the child invocation independently proves the complete constrained-host environment
217+
- **THEN** `-NoProfile` by itself does not prove the inherited environment or module baseline
218+
219+
#### Scenario: Current-runspace evaluation shares state
220+
- **WHEN** a supported `$()` or static `Invoke-Expression` region mutates a loop-relevant binding or command-resolution fact
221+
- **THEN** every later observing occurrence is joined with or downgraded to the resulting conservative state
222+
171223
### Requirement: Shell values use explicit proof domains
172224
The analysis SHALL classify a policy-relevant shell value as exact, finite,
173225
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: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,11 @@
128128
iterator/body state or command-resolution mutation and dynamic invocation
129129
fail atomically. Current-scope continuations after a loop remain incomplete;
130130
isolated child-host loops do not taint their outer continuation.
131+
- [x] 7.2a Add the explicit `PwshInitialStateMode` contract and safe default
132+
before value analysis. Lock the constrained noninteractive no-profile host
133+
and module baseline, current-runspace sharing, child-host noninheritance,
134+
mutation invalidation, and ambient typed/read-only binding hazards in the
135+
canonical specs and case-specific design corpus.
131136
- [ ] 7.3 Derive exact and finite string domains without treating pipeline objects as literal strings.
132137
- [ ] 7.4 Propagate PowerShell scope and location state according to the locked statement semantics.
133138
- [ ] 7.5 Cover aliases, cmdlets, native commands, nested loops, pipelines, script blocks, and wrapper boundaries.

0 commit comments

Comments
 (0)