diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 5c89767..46e0cba 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -256,10 +256,9 @@ priorities. roles, ancestry, completeness, nullable decoded spans, compatibility operators, and exact shared `Clause` identity. The strict DTO rejects unknown fields and always requires unparseable projections to be empty. - The PowerShell manifest owns the first 361 entries and round-trips them - exactly; isolated-state v0.3 entries 362-372 remain explicitly curated - until the generator accepts a case-specific initial-state mode. Explicit - false/null assertions remain opt-in and generator-preserved. + The PowerShell manifest owns all 422 entries and round-trips them exactly, + including case-specific isolated-state inputs. Explicit false/null + assertions remain opt-in and generator-preserved. - [x] Deliver the first Bash `$()` substitution slice for supported simple-command arguments and redirect targets. Direct tests and corpus entries pin multiple and nested ordering, exact ancestry/spans, isolated @@ -402,7 +401,7 @@ priorities. delivered for ordinary, adjacent, quoted, here-string, redirect, standalone, call-operator, dynamic-identity, and host-wrapper positions, with current-scope state propagation and bounded expression rejection pinned - by the 393-entry executable corpus. Remote/session/SSH/VM/container + by the generated executable corpus. Remote/session/SSH/VM/container `Invoke-Command` now starts from arbitrary child state, isolates all exit effects, publishes synchronous/once only for one proved target, and publishes concurrent timing for multiple targets or enabled asynchronous @@ -437,8 +436,10 @@ priorities. facts. Supported catalog-owned module qualifications now pass structural admission because every possible body remains visible; the occurrence analyzer still withholds typed receiver facts after an observed command- - resolution mutation unless the authored module qualification proves the - identity independently. The first direct-operator sub-slice now handles + resolution mutation unless bounded mutation provenance proves the exact + authored spelling unaffected. PowerShell permits an exact alias whose + name looks module-qualified, so module qualification is not independent + identity proof. The first direct-operator sub-slice now handles currently supported command interiors in typed synchronous `& {}` and `. {}` regions without synthetic host commands. It isolates ordinary direct-call binding and command-resolution exit mutation, invalidates @@ -503,10 +504,15 @@ priorities. Stable v0.3 stops at the delivered Start-Job, Parallel, and remote/session boundaries. Optional-module Start-ThreadJob and exact deferred breakpoint/event/completion actions are post-v0.3 catalog work; unknown - receivers continue to expose incomplete bodies. Preserve script blocks proved - to be data as opaque values, expose ambiguous bodies with incomplete - facts, and fail atomically when any potentially executable interior is - unsupported. Local PowerShell 7.6.4 probes pin variable-versus-location + receivers continue to expose incomplete bodies. Script blocks consumed + by a proved canonical, alias, or supported module-qualified `Write-Output` + receiver now remain opaque data under constrained, bounded command- + resolution state. Unknown receivers and exact identities changed by + observed alias mutation expose incomplete executable bodies instead. + Generated corpus entries 418-422 pin proved data, the fail-closed unknown + receiver, proved local `Invoke-Command`, and the exact module-qualified- + looking and canonical-target alias boundaries; unsupported potentially + executable interiors still fail atomically. Local PowerShell 7.6.4 probes pin variable-versus-location independence, semantic phase order, child process/runspace boundaries, boundaries and the fact that the in-process `Invoke-Command` parameter set does not support `-AsJob`. diff --git a/SPEC.POWERSHELL.md b/SPEC.POWERSHELL.md index 0903cce..9a74867 100644 --- a/SPEC.POWERSHELL.md +++ b/SPEC.POWERSHELL.md @@ -568,6 +568,11 @@ execution and state facts. Aliases, supported module-qualified spellings, static call-operator spellings, parameter abbreviations and inline values, positional binding, parameter-set selection, and `ScriptBlock[]` binding resolve through the same static catalog. +Catalog lookup is not identity proof: PowerShell permits an alias whose exact +name looks module-qualified. A constrained baseline plus bounded mutation +provenance must prove the authored spelling unchanged before a catalog entry +can classify a script block as non-executing data. Mutation matching covers +both the authored spelling and its known canonical alias target. PowerShell's special multiple-script-block binding for `ForEach-Object` assigns Begin, Process, and End phases semantically; authored syntax and occurrence projection remain in source order while the analyzer schedules phases in diff --git a/openspec/changes/v0-3-structured-shell-analysis/design.md b/openspec/changes/v0-3-structured-shell-analysis/design.md index 1827af6..11fbc07 100644 --- a/openspec/changes/v0-3-structured-shell-analysis/design.md +++ b/openspec/changes/v0-3-structured-shell-analysis/design.md @@ -435,6 +435,11 @@ follow the unknown-receiver rule and retain visible bodies with incomplete facts. Aliases, supported module-qualified spellings, static call operator spellings, parameter abbreviations/inline values, positional binding, parameter sets, and `ScriptBlock[]` binding use the same static catalog. +Catalog lookup is separate from identity proof: PowerShell permits an alias +whose exact name looks module-qualified. A constrained baseline plus bounded +mutation provenance must prove the authored spelling unchanged before a +catalog entry can hide a script block as data. Mutation matching covers both +the authored spelling and its known canonical alias target. An unknown receiver or ambiguous binding is over-approximated as an execution region with unknown facts. Its body commands remain visible and affected 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 30f372b..ff6a0e0 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 @@ -860,6 +860,17 @@ SHALL fail closed to every unproved command name. - **THEN** their execution-region facts and observing continuation are incomplete - **THEN** an unsupported body interior makes the whole result unparseable +#### Scenario: Module-qualified-looking data receiver is shadowable +- **WHEN** isolated-mode PowerShell assigns alias name `Microsoft.PowerShell.Utility\Write-Output` to `Invoke-Command` and then invokes that exact spelling with `{ Remove-Item target.txt }` +- **THEN** static catalog lookup does not independently prove the receiver identity +- **THEN** the `Remove-Item` body remains visible in an unknown incomplete execution region +- **THEN** an unrelated exact alias mutation does not invalidate a different catalog spelling + +#### Scenario: Canonical alias-target mutation invalidates authored alias +- **WHEN** isolated-mode PowerShell reassigns `Write-Output` to `Invoke-Command` and then invokes `echo { Remove-Item target.txt }` +- **THEN** receiver proof matches the mutation against canonical `Write-Output` as well as authored `echo` +- **THEN** the `Remove-Item` body remains visible in an unknown incomplete execution region + ### Requirement: Unknown analysis remains policy-sensitive An unknown value SHALL identify the occurrence and position it affects so a consumer can determine whether command identity, option parsing, path scope, diff --git a/openspec/changes/v0-3-structured-shell-analysis/tasks.md b/openspec/changes/v0-3-structured-shell-analysis/tasks.md index d8d77ce..edc7e49 100644 --- a/openspec/changes/v0-3-structured-shell-analysis/tasks.md +++ b/openspec/changes/v0-3-structured-shell-analysis/tasks.md @@ -230,9 +230,17 @@ boundaries conservatively. Additional optional-module `Start-ThreadJob` proof is not a stable-v0.3 requirement; unproved forms follow the unknown- receiver rule. - - [ ] 7.5e Keep proved non-executing script-block data opaque; over-approximate + - [x] 7.5e Keep proved non-executing script-block data opaque; over-approximate unknown receivers/bindings as unknown incomplete regions; fail atomically on unsupported interiors or state transfers. + - Isolated-state canonical, alias, and supported module-qualified + `Write-Output` receivers keep script blocks opaque only while bounded + command-resolution state proves that exact authored spelling unchanged. + PowerShell permits an alias whose name is itself module-qualified-looking, + so default state or a matching observed mutation downgrades to an unknown + incomplete region. Executable-corpus entries pin proved data, an unknown + receiver, proved local `Invoke-Command`, the exact module-qualified- + looking alias boundary, and canonical-target invalidation through `echo`. - [ ] 7.5f Pin authored projection order separately from semantic phase order, exact host element coordinates, nested regions, wrappers, pipelines, loops, and the 16-container depth boundary. @@ -241,8 +249,9 @@ grammar is not required for stable v0.3. - [ ] 7.6 Add adversarial cases for object-valued iterables, mutation, dynamic invocation, splatting, and cap overflow. - [ ] 7.7 Add PowerShell corpus entries, live `pwsh` oracle coverage, and Netclaw integration cases. - - Add case-specific `PwshInitialStateMode` support to `PwshCorpusTool` before - folding isolated-state entries 362+ into its generated manifest. + - `PwshCorpusTool` now supports case-specific `PwshInitialStateMode`; keep + promoting the remaining stable execution-region and adversarial cases into + its generated manifest, then add the Netclaw PowerShell policy matrix. ## 10. Heredoc / Here-String Slice diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachValueAnalysis.cs b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachValueAnalysis.cs index 09631a9..f8418b0 100644 --- a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachValueAnalysis.cs +++ b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachValueAnalysis.cs @@ -1715,7 +1715,11 @@ internal static bool TryAnalyze( initialWorkingDirectory, canPromote: options.InitialStateMode == PwshInitialStateMode.IsolatedNonInteractiveNoProfile, + hasConstrainedCommandResolutionBaseline: + options.InitialStateMode == + PwshInitialStateMode.IsolatedNonInteractiveNoProfile, commandResolutionInvalidated: false, + commandResolutionInvalidatedBeyondTrackedMutations: false, allRunspaceCommandResolutionMayReachProcessMutation: false, Array.Empty(), processWideStateInvalidated: false, @@ -1934,7 +1938,9 @@ private static AnalysisContext ApplyPersistentStateInvalidation( IReadOnlyList mutatedCommandNames, bool mayEscapeChildRunspaceProcess) { - var invalidated = input.Invalidate(unknownCwd); + var invalidated = input.Invalidate( + unknownCwd, + invalidateCommandResolution: !hasCommandResolutionMutation); if (hasCommandResolutionMutation) { invalidated = invalidated.WithRunspaceCommandResolutionProcessRisk( @@ -1957,12 +1963,21 @@ private PwshFlowResult ApplyExecutionRegionEffect( return flow; } - var commandIdentityProven = IsCommandIdentityProven( + var receiverIdentityProven = IsExecutionRegionReceiverIdentityProven( simple.Clause, receiverInput); var binding = PwshExecutionRegionBindingCatalog.Bind( simple.Clause, - commandIdentityProven); + receiverIdentityProven); + if (binding.Status == PwshExecutionRegionBindingStatus.ProvedData) + { + RecordExecutionRegions( + simple.Clause, + Array.Empty(), + simple.ExecutionRegions); + return flow; + } + if (!IsSupportedExecutionRegionReceiver(binding) || !TryApplyExecutionRegionBindings(simple, binding, out var regions)) { @@ -1971,7 +1986,7 @@ private PwshFlowResult ApplyExecutionRegionEffect( simple.ExecutionRegions, simple.ExecutionRegions); _executionRegionEffectCount++; - if (commandIdentityProven && + if (receiverIdentityProven && binding.Receiver == PwshExecutionRegionReceiver.StartJob) { return flow; @@ -2499,11 +2514,17 @@ private static bool IsCommandIdentityProven( return true; } - return clause.Verb.Tokens.Count == 1 && - PwshExecutionRegionBindingCatalog.IsSupportedModuleQualifiedCommand( - clause.Verb.Tokens[0]); + return !input.CommandResolutionInvalidatedBeyondTrackedMutations && + !input.MayResolveCommandToProcessMutation(clause); } + private static bool IsExecutionRegionReceiverIdentityProven( + Clause clause, + AnalysisContext input) => + input.HasConstrainedCommandResolutionBaseline && + !input.CommandResolutionInvalidatedBeyondTrackedMutations && + !input.MayResolveCommandToProcessMutation(clause); + private void RecordExecutionRegions( Clause clause, IReadOnlyList regions, @@ -4532,7 +4553,9 @@ private readonly struct AnalysisContext internal AnalysisContext( string? workingDirectory, bool canPromote, + bool hasConstrainedCommandResolutionBaseline, bool commandResolutionInvalidated, + bool commandResolutionInvalidatedBeyondTrackedMutations, bool allRunspaceCommandResolutionMayReachProcessMutation, IReadOnlyList runspaceProcessMutationCommandNames, bool processWideStateInvalidated, @@ -4540,7 +4563,11 @@ internal AnalysisContext( { WorkingDirectory = workingDirectory; CanPromote = canPromote; + HasConstrainedCommandResolutionBaseline = + hasConstrainedCommandResolutionBaseline; CommandResolutionInvalidated = commandResolutionInvalidated; + CommandResolutionInvalidatedBeyondTrackedMutations = + commandResolutionInvalidatedBeyondTrackedMutations; AllRunspaceCommandResolutionMayReachProcessMutation = allRunspaceCommandResolutionMayReachProcessMutation; _runspaceProcessMutationCommandNames = @@ -4553,8 +4580,12 @@ internal AnalysisContext( internal bool CanPromote { get; } + internal bool HasConstrainedCommandResolutionBaseline { get; } + internal bool CommandResolutionInvalidated { get; } + internal bool CommandResolutionInvalidatedBeyondTrackedMutations { get; } + internal bool AllRunspaceCommandResolutionMayReachProcessMutation { get; } internal bool ProcessWideStateInvalidated { get; } @@ -4577,10 +4608,15 @@ internal bool MayResolveCommandToProcessMutation(Clause clause) } var authoredName = clause.Verb.Tokens[0]; + var canonicalName = clause.Verb.CanonicalVerb; foreach (var commandName in _runspaceProcessMutationCommandNames) { if (commandName.Equals( authoredName, + StringComparison.OrdinalIgnoreCase) || + canonicalName is not null && + commandName.Equals( + canonicalName, StringComparison.OrdinalIgnoreCase)) { return true; @@ -4601,7 +4637,10 @@ internal AnalysisContext Invalidate( return new AnalysisContext( unknownCwd ? null : WorkingDirectory, false, + HasConstrainedCommandResolutionBaseline, commandResolutionInvalidated, + CommandResolutionInvalidatedBeyondTrackedMutations || + invalidateCommandResolution, AllRunspaceCommandResolutionMayReachProcessMutation, _runspaceProcessMutationCommandNames, ProcessWideStateInvalidated, @@ -4619,7 +4658,10 @@ internal AnalysisContext Invalidate( return new AnalysisContext( unknownCwd ? null : WorkingDirectory, false, + HasConstrainedCommandResolutionBaseline, commandResolutionInvalidated, + CommandResolutionInvalidatedBeyondTrackedMutations || + invalidateCommandResolution, AllRunspaceCommandResolutionMayReachProcessMutation, _runspaceProcessMutationCommandNames, ProcessWideStateInvalidated, @@ -4630,7 +4672,9 @@ internal AnalysisContext WithRunspaceCommandResolutionProcessRisk() => new( WorkingDirectory, CanPromote, + HasConstrainedCommandResolutionBaseline, commandResolutionInvalidated: true, + commandResolutionInvalidatedBeyondTrackedMutations: true, allRunspaceCommandResolutionMayReachProcessMutation: true, Array.Empty(), ProcessWideStateInvalidated, @@ -4660,7 +4704,9 @@ internal AnalysisContext WithRunspaceCommandResolutionProcessRisk( : new AnalysisContext( WorkingDirectory, CanPromote, + HasConstrainedCommandResolutionBaseline, commandResolutionInvalidated: true, + CommandResolutionInvalidatedBeyondTrackedMutations, allRunspaceCommandResolutionMayReachProcessMutation: false, names, ProcessWideStateInvalidated, @@ -4679,7 +4725,9 @@ internal AnalysisContext WithProcessWideStateInvalidated() => new( WorkingDirectory, CanPromote, + HasConstrainedCommandResolutionBaseline, commandResolutionInvalidated: true, + commandResolutionInvalidatedBeyondTrackedMutations: true, AllRunspaceCommandResolutionMayReachProcessMutation, _runspaceProcessMutationCommandNames, processWideStateInvalidated: true, @@ -4694,7 +4742,9 @@ internal AnalysisContext WithoutBindings() => new( WorkingDirectory, CanPromote, + HasConstrainedCommandResolutionBaseline, CommandResolutionInvalidated, + CommandResolutionInvalidatedBeyondTrackedMutations, AllRunspaceCommandResolutionMayReachProcessMutation, _runspaceProcessMutationCommandNames, ProcessWideStateInvalidated, @@ -4704,7 +4754,10 @@ internal AnalysisContext CreateChildProcessInput() => new( WorkingDirectory, canPromote: false, + hasConstrainedCommandResolutionBaseline: false, commandResolutionInvalidated: ProcessWideStateInvalidated, + commandResolutionInvalidatedBeyondTrackedMutations: + ProcessWideStateInvalidated, allRunspaceCommandResolutionMayReachProcessMutation: false, Array.Empty(), ProcessWideStateInvalidated, @@ -4717,7 +4770,9 @@ internal AnalysisContext CreateRemoteInput() => new( workingDirectory: null, canPromote: false, + hasConstrainedCommandResolutionBaseline: false, commandResolutionInvalidated: true, + commandResolutionInvalidatedBeyondTrackedMutations: true, allRunspaceCommandResolutionMayReachProcessMutation: false, Array.Empty(), processWideStateInvalidated: false, @@ -4729,7 +4784,10 @@ internal AnalysisContext CreateChildRunspaceInput() => new( WorkingDirectory, CanPromote, + HasConstrainedCommandResolutionBaseline, commandResolutionInvalidated: ProcessWideStateInvalidated, + commandResolutionInvalidatedBeyondTrackedMutations: + ProcessWideStateInvalidated, allRunspaceCommandResolutionMayReachProcessMutation: false, Array.Empty(), ProcessWideStateInvalidated, @@ -4739,7 +4797,9 @@ internal AnalysisContext WithCwd(string? workingDirectory) => new( workingDirectory, CanPromote, + HasConstrainedCommandResolutionBaseline, CommandResolutionInvalidated, + CommandResolutionInvalidatedBeyondTrackedMutations, AllRunspaceCommandResolutionMayReachProcessMutation, _runspaceProcessMutationCommandNames, ProcessWideStateInvalidated, @@ -4764,7 +4824,9 @@ internal AnalysisContext WithBinding( return new AnalysisContext( WorkingDirectory, CanPromote, + HasConstrainedCommandResolutionBaseline, CommandResolutionInvalidated, + CommandResolutionInvalidatedBeyondTrackedMutations, AllRunspaceCommandResolutionMayReachProcessMutation, _runspaceProcessMutationCommandNames, ProcessWideStateInvalidated, @@ -5022,7 +5084,11 @@ internal bool StateEquals(AnalysisContext other) other.WorkingDirectory, StringComparison.Ordinal) || CanPromote != other.CanPromote || + HasConstrainedCommandResolutionBaseline != + other.HasConstrainedCommandResolutionBaseline || CommandResolutionInvalidated != other.CommandResolutionInvalidated || + CommandResolutionInvalidatedBeyondTrackedMutations != + other.CommandResolutionInvalidatedBeyondTrackedMutations || AllRunspaceCommandResolutionMayReachProcessMutation != other.AllRunspaceCommandResolutionMayReachProcessMutation || ProcessWideStateInvalidated != other.ProcessWideStateInvalidated || @@ -5106,8 +5172,12 @@ leftBinding is null || rightBinding is null ? left.WorkingDirectory : null, left.CanPromote && right.CanPromote, + left.HasConstrainedCommandResolutionBaseline && + right.HasConstrainedCommandResolutionBaseline, left.CommandResolutionInvalidated || right.CommandResolutionInvalidated, + left.CommandResolutionInvalidatedBeyondTrackedMutations || + right.CommandResolutionInvalidatedBeyondTrackedMutations, invalidatesAllCommandNames, invalidatesAllCommandNames ? Array.Empty() diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/418_v03_script_block_proved_data.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/418_v03_script_block_proved_data.json new file mode 100644 index 0000000..c1cb51c --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/418_v03_script_block_proved_data.json @@ -0,0 +1,74 @@ +{ + "name": "V03 script block proved data", + "input": "Write-Output { Remove-Item target.txt }", + "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": [ + "Write-Output" + ], + "args": [ + { + "raw": "{ Remove-Item target.txt }", + "kind": "DynamicSkip", + "isPath": false + } + ], + "redirects": [] + } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 39, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 39, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 39 + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Exact", + "values": [ + "C:/work" + ], + "pattern": null, + "coveringDirectory": null + } + } + ] + }, + "notes": "A proved canonical data receiver keeps a script block opaque instead of inventing a Remove-Item command occurrence." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/419_v03_script_block_unknown_receiver.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/419_v03_script_block_unknown_receiver.json new file mode 100644 index 0000000..2f745bc --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/419_v03_script_block_unknown_receiver.json @@ -0,0 +1,214 @@ +{ + "name": "V03 script block unknown receiver", + "input": "Invoke-Custom { Remove-Item target.txt }", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": [ + "Invoke-Custom" + ], + "args": [ + { + "raw": "{ Remove-Item target.txt }", + "kind": "DynamicSkip", + "isPath": false + } + ], + "redirects": [], + "elements": [ + { + "raw": "Invoke-Custom", + "value": "Invoke-Custom", + "role": "Verb", + "sourceStart": 0, + "sourceLength": 13, + "precedingVerbElementCount": 0, + "kind": "Literal", + "isFlag": false, + "isPath": false + }, + { + "raw": "{ Remove-Item target.txt }", + "value": "{ Remove-Item target.txt }", + "role": "Argument", + "sourceStart": 14, + "sourceLength": 26, + "precedingVerbElementCount": 1, + "kind": "DynamicSkip", + "isFlag": false, + "isPath": false + } + ] + }, + { + "operator": "None", + "verb": [ + "Remove-Item" + ], + "args": [ + { + "raw": "target.txt", + "kind": "Literal", + "isPath": true + }, + { + "raw": "\u003Cdynamic-cwd\u003E", + "kind": "DynamicSkip", + "isPath": false, + "isCwdAttribution": true + } + ], + "redirects": [], + "elements": [ + { + "raw": "Remove-Item", + "value": "Remove-Item", + "role": "Verb", + "sourceStart": 16, + "sourceLength": 11, + "precedingVerbElementCount": 0, + "kind": "Literal", + "isFlag": false, + "isPath": false + }, + { + "raw": "target.txt", + "value": "target.txt", + "role": "Argument", + "sourceStart": 28, + "sourceLength": 10, + "precedingVerbElementCount": 1, + "kind": "Literal", + "isFlag": false, + "isPath": true + } + ] + } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 40, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 40, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + }, + { + "kind": "ExecutionRegion", + "parentIndex": 1, + "region": "ExecutionRegion", + "childIndex": 0, + "sourceStart": 14, + "sourceLength": 26, + "clauseIndex": null, + "groupKind": null, + "listOperator": null, + "executionOrigin": "CommandArgument", + "hostClauseElementIndex": 1, + "executionPhase": "Unknown", + "executionTiming": "Unknown", + "executionCardinality": "Unknown" + }, + { + "kind": "Block", + "parentIndex": 2, + "region": "ExecutionRegion", + "childIndex": 0, + "sourceStart": 15, + "sourceLength": 24, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 3, + "region": "Statement", + "childIndex": 0, + "sourceStart": 16, + "sourceLength": 22, + "clauseIndex": 1, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Ordinary", + "isComplete": false, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 40 + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Exact", + "values": [ + "C:/work" + ], + "pattern": null, + "coveringDirectory": null + } + }, + { + "clauseIndex": 1, + "immediateRole": "ExecutionRegion", + "isComplete": false, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 40 + }, + { + "ancestorKind": "ExecutionRegion", + "region": "ExecutionRegion", + "childIndex": 0, + "sourceStart": 14, + "sourceLength": 26 + }, + { + "ancestorKind": "Block", + "region": "Statement", + "childIndex": 0, + "sourceStart": 15, + "sourceLength": 24 + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Unknown", + "values": [], + "pattern": null, + "coveringDirectory": null + } + } + ] + }, + "notes": "An unproved receiver over-approximates the script block as an incomplete execution region so its nested command remains visible." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/420_v03_invoke_command_local_region.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/420_v03_invoke_command_local_region.json new file mode 100644 index 0000000..109db4c --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/420_v03_invoke_command_local_region.json @@ -0,0 +1,213 @@ +{ + "name": "V03 invoke command local region", + "input": "Invoke-Command { Remove-Item secret.txt }", + "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": [ + "Invoke-Command" + ], + "args": [ + { + "raw": "{ Remove-Item secret.txt }", + "kind": "DynamicSkip", + "isPath": false + } + ], + "redirects": [], + "elements": [ + { + "raw": "Invoke-Command", + "value": "Invoke-Command", + "role": "Verb", + "sourceStart": 0, + "sourceLength": 14, + "precedingVerbElementCount": 0, + "kind": "Literal", + "isFlag": false, + "isPath": false + }, + { + "raw": "{ Remove-Item secret.txt }", + "value": "{ Remove-Item secret.txt }", + "role": "Argument", + "sourceStart": 15, + "sourceLength": 26, + "precedingVerbElementCount": 1, + "kind": "DynamicSkip", + "isFlag": false, + "isPath": false + } + ] + }, + { + "operator": "None", + "verb": [ + "Remove-Item" + ], + "args": [ + { + "raw": "secret.txt", + "kind": "Literal", + "isPath": true, + "resolved": "C:/work/secret.txt" + } + ], + "redirects": [], + "elements": [ + { + "raw": "Remove-Item", + "value": "Remove-Item", + "role": "Verb", + "sourceStart": 17, + "sourceLength": 11, + "precedingVerbElementCount": 0, + "kind": "Literal", + "isFlag": false, + "isPath": false + }, + { + "raw": "secret.txt", + "value": "secret.txt", + "role": "Argument", + "sourceStart": 29, + "sourceLength": 10, + "precedingVerbElementCount": 1, + "kind": "Literal", + "isFlag": false, + "isPath": true, + "resolved": "C:/work/secret.txt" + } + ] + } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 41, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 41, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + }, + { + "kind": "ExecutionRegion", + "parentIndex": 1, + "region": "ExecutionRegion", + "childIndex": 0, + "sourceStart": 15, + "sourceLength": 26, + "clauseIndex": null, + "groupKind": null, + "listOperator": null, + "executionOrigin": "CommandArgument", + "hostClauseElementIndex": 1, + "executionPhase": "Main", + "executionTiming": "Synchronous", + "executionCardinality": "Once" + }, + { + "kind": "Block", + "parentIndex": 2, + "region": "ExecutionRegion", + "childIndex": 0, + "sourceStart": 16, + "sourceLength": 24, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 3, + "region": "Statement", + "childIndex": 0, + "sourceStart": 17, + "sourceLength": 22, + "clauseIndex": 1, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 41 + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Exact", + "values": [ + "C:/work" + ], + "pattern": null, + "coveringDirectory": null + } + }, + { + "clauseIndex": 1, + "immediateRole": "ExecutionRegion", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 41 + }, + { + "ancestorKind": "ExecutionRegion", + "region": "ExecutionRegion", + "childIndex": 0, + "sourceStart": 15, + "sourceLength": 26 + }, + { + "ancestorKind": "Block", + "region": "Statement", + "childIndex": 0, + "sourceStart": 16, + "sourceLength": 24 + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Exact", + "values": [ + "C:/work" + ], + "pattern": null, + "coveringDirectory": null + } + } + ] + }, + "notes": "A proved local Invoke-Command receiver exposes its positional script block as one synchronous execution region." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/421_v03_module_qualified_data_exact_alias.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/421_v03_module_qualified_data_exact_alias.json new file mode 100644 index 0000000..16018ad --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/421_v03_module_qualified_data_exact_alias.json @@ -0,0 +1,347 @@ +{ + "name": "V03 module qualified data exact alias", + "input": "Set-Alias \u0027Microsoft.PowerShell.Utility\\Write-Output\u0027 Invoke-Command; Microsoft.PowerShell.Utility\\Write-Output { Remove-Item victim.txt }", + "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": [ + "Set-Alias" + ], + "args": [ + { + "raw": "\u0027Microsoft.PowerShell.Utility\\Write-Output\u0027", + "kind": "Literal", + "isPath": true, + "resolved": "C:/work/Microsoft.PowerShell.Utility/Write-Output" + }, + { + "raw": "Invoke-Command", + "kind": "Literal", + "isPath": false + } + ], + "redirects": [], + "elements": [ + { + "raw": "Set-Alias", + "value": "Set-Alias", + "role": "Verb", + "sourceStart": 0, + "sourceLength": 9, + "precedingVerbElementCount": 0, + "kind": "Literal", + "isFlag": false, + "isPath": false + }, + { + "raw": "\u0027Microsoft.PowerShell.Utility\\Write-Output\u0027", + "value": "Microsoft.PowerShell.Utility\\Write-Output", + "role": "Argument", + "sourceStart": 10, + "sourceLength": 43, + "precedingVerbElementCount": 1, + "kind": "Literal", + "isFlag": false, + "isPath": true, + "resolved": "C:/work/Microsoft.PowerShell.Utility/Write-Output" + }, + { + "raw": "Invoke-Command", + "value": "Invoke-Command", + "role": "Argument", + "sourceStart": 54, + "sourceLength": 14, + "precedingVerbElementCount": 1, + "kind": "Literal", + "isFlag": false, + "isPath": false + } + ] + }, + { + "operator": "Sequence", + "verb": [ + "Microsoft.PowerShell.Utility\\Write-Output" + ], + "args": [ + { + "raw": "{ Remove-Item victim.txt }", + "kind": "DynamicSkip", + "isPath": false + } + ], + "redirects": [], + "elements": [ + { + "raw": "Microsoft.PowerShell.Utility\\Write-Output", + "value": "Microsoft.PowerShell.Utility\\Write-Output", + "role": "Verb", + "sourceStart": 70, + "sourceLength": 41, + "precedingVerbElementCount": 0, + "kind": "Literal", + "isFlag": false, + "isPath": false + }, + { + "raw": "{ Remove-Item victim.txt }", + "value": "{ Remove-Item victim.txt }", + "role": "Argument", + "sourceStart": 112, + "sourceLength": 26, + "precedingVerbElementCount": 1, + "kind": "DynamicSkip", + "isFlag": false, + "isPath": false + } + ] + }, + { + "operator": "None", + "verb": [ + "Remove-Item" + ], + "args": [ + { + "raw": "victim.txt", + "kind": "Literal", + "isPath": true + }, + { + "raw": "\u003Cdynamic-cwd\u003E", + "kind": "DynamicSkip", + "isPath": false, + "isCwdAttribution": true + } + ], + "redirects": [], + "elements": [ + { + "raw": "Remove-Item", + "value": "Remove-Item", + "role": "Verb", + "sourceStart": 114, + "sourceLength": 11, + "precedingVerbElementCount": 0, + "kind": "Literal", + "isFlag": false, + "isPath": false + }, + { + "raw": "victim.txt", + "value": "victim.txt", + "role": "Argument", + "sourceStart": 126, + "sourceLength": 10, + "precedingVerbElementCount": 1, + "kind": "Literal", + "isFlag": false, + "isPath": true + } + ] + } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 138, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "CommandList", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 138, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 1, + "region": "Statement", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 68, + "clauseIndex": 0, + "groupKind": null, + "listOperator": "None" + }, + { + "kind": "SimpleCommand", + "parentIndex": 1, + "region": "Statement", + "childIndex": 1, + "sourceStart": 70, + "sourceLength": 68, + "clauseIndex": 1, + "groupKind": null, + "listOperator": "Sequence" + }, + { + "kind": "ExecutionRegion", + "parentIndex": 3, + "region": "ExecutionRegion", + "childIndex": 0, + "sourceStart": 112, + "sourceLength": 26, + "clauseIndex": null, + "groupKind": null, + "listOperator": null, + "executionOrigin": "CommandArgument", + "hostClauseElementIndex": 1, + "executionPhase": "Unknown", + "executionTiming": "Unknown", + "executionCardinality": "Unknown" + }, + { + "kind": "Block", + "parentIndex": 4, + "region": "ExecutionRegion", + "childIndex": 0, + "sourceStart": 113, + "sourceLength": 24, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 5, + "region": "Statement", + "childIndex": 0, + "sourceStart": 114, + "sourceLength": 22, + "clauseIndex": 2, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 138 + }, + { + "ancestorKind": "CommandList", + "region": "Statement", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 138 + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Exact", + "values": [ + "C:/work" + ], + "pattern": null, + "coveringDirectory": null + } + }, + { + "clauseIndex": 1, + "immediateRole": "Ordinary", + "isComplete": false, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 138 + }, + { + "ancestorKind": "CommandList", + "region": "Statement", + "childIndex": 1, + "sourceStart": 0, + "sourceLength": 138 + } + ], + "effectiveArguments": [ + { + "clauseElementIndex": 1, + "value": { + "kind": "Unknown", + "values": [], + "pattern": null, + "coveringDirectory": null + } + } + ], + "workingDirectory": { + "kind": "Exact", + "values": [ + "C:/work" + ], + "pattern": null, + "coveringDirectory": null + } + }, + { + "clauseIndex": 2, + "immediateRole": "ExecutionRegion", + "isComplete": false, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 138 + }, + { + "ancestorKind": "CommandList", + "region": "Statement", + "childIndex": 1, + "sourceStart": 0, + "sourceLength": 138 + }, + { + "ancestorKind": "ExecutionRegion", + "region": "ExecutionRegion", + "childIndex": 0, + "sourceStart": 112, + "sourceLength": 26 + }, + { + "ancestorKind": "Block", + "region": "Statement", + "childIndex": 0, + "sourceStart": 113, + "sourceLength": 24 + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Unknown", + "values": [], + "pattern": null, + "coveringDirectory": null + } + } + ] + }, + "notes": "An exact alias can shadow a module-qualified-looking token, so the nested command remains visible and incomplete." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/422_v03_data_receiver_canonical_alias_mutation.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/422_v03_data_receiver_canonical_alias_mutation.json new file mode 100644 index 0000000..bf89af8 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/422_v03_data_receiver_canonical_alias_mutation.json @@ -0,0 +1,346 @@ +{ + "name": "V03 data receiver canonical alias mutation", + "input": "Set-Alias Write-Output Invoke-Command; echo { Remove-Item victim.txt }", + "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": [ + "Set-Alias" + ], + "args": [ + { + "raw": "Write-Output", + "kind": "Literal", + "isPath": false + }, + { + "raw": "Invoke-Command", + "kind": "Literal", + "isPath": false + } + ], + "redirects": [], + "elements": [ + { + "raw": "Set-Alias", + "value": "Set-Alias", + "role": "Verb", + "sourceStart": 0, + "sourceLength": 9, + "precedingVerbElementCount": 0, + "kind": "Literal", + "isFlag": false, + "isPath": false + }, + { + "raw": "Write-Output", + "value": "Write-Output", + "role": "Argument", + "sourceStart": 10, + "sourceLength": 12, + "precedingVerbElementCount": 1, + "kind": "Literal", + "isFlag": false, + "isPath": false + }, + { + "raw": "Invoke-Command", + "value": "Invoke-Command", + "role": "Argument", + "sourceStart": 23, + "sourceLength": 14, + "precedingVerbElementCount": 1, + "kind": "Literal", + "isFlag": false, + "isPath": false + } + ] + }, + { + "operator": "Sequence", + "verb": [ + "echo" + ], + "canonicalVerb": "Write-Output", + "args": [ + { + "raw": "{ Remove-Item victim.txt }", + "kind": "DynamicSkip", + "isPath": false + } + ], + "redirects": [], + "elements": [ + { + "raw": "echo", + "value": "echo", + "role": "Verb", + "sourceStart": 39, + "sourceLength": 4, + "precedingVerbElementCount": 0, + "kind": "Literal", + "isFlag": false, + "isPath": false + }, + { + "raw": "{ Remove-Item victim.txt }", + "value": "{ Remove-Item victim.txt }", + "role": "Argument", + "sourceStart": 44, + "sourceLength": 26, + "precedingVerbElementCount": 1, + "kind": "DynamicSkip", + "isFlag": false, + "isPath": false + } + ] + }, + { + "operator": "None", + "verb": [ + "Remove-Item" + ], + "args": [ + { + "raw": "victim.txt", + "kind": "Literal", + "isPath": true + }, + { + "raw": "\u003Cdynamic-cwd\u003E", + "kind": "DynamicSkip", + "isPath": false, + "isCwdAttribution": true + } + ], + "redirects": [], + "elements": [ + { + "raw": "Remove-Item", + "value": "Remove-Item", + "role": "Verb", + "sourceStart": 46, + "sourceLength": 11, + "precedingVerbElementCount": 0, + "kind": "Literal", + "isFlag": false, + "isPath": false + }, + { + "raw": "victim.txt", + "value": "victim.txt", + "role": "Argument", + "sourceStart": 58, + "sourceLength": 10, + "precedingVerbElementCount": 1, + "kind": "Literal", + "isFlag": false, + "isPath": true + } + ] + } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 70, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "CommandList", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 70, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 1, + "region": "Statement", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 37, + "clauseIndex": 0, + "groupKind": null, + "listOperator": "None" + }, + { + "kind": "SimpleCommand", + "parentIndex": 1, + "region": "Statement", + "childIndex": 1, + "sourceStart": 39, + "sourceLength": 31, + "clauseIndex": 1, + "groupKind": null, + "listOperator": "Sequence" + }, + { + "kind": "ExecutionRegion", + "parentIndex": 3, + "region": "ExecutionRegion", + "childIndex": 0, + "sourceStart": 44, + "sourceLength": 26, + "clauseIndex": null, + "groupKind": null, + "listOperator": null, + "executionOrigin": "CommandArgument", + "hostClauseElementIndex": 1, + "executionPhase": "Unknown", + "executionTiming": "Unknown", + "executionCardinality": "Unknown" + }, + { + "kind": "Block", + "parentIndex": 4, + "region": "ExecutionRegion", + "childIndex": 0, + "sourceStart": 45, + "sourceLength": 24, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 5, + "region": "Statement", + "childIndex": 0, + "sourceStart": 46, + "sourceLength": 22, + "clauseIndex": 2, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 70 + }, + { + "ancestorKind": "CommandList", + "region": "Statement", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 70 + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Exact", + "values": [ + "C:/work" + ], + "pattern": null, + "coveringDirectory": null + } + }, + { + "clauseIndex": 1, + "immediateRole": "Ordinary", + "isComplete": false, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 70 + }, + { + "ancestorKind": "CommandList", + "region": "Statement", + "childIndex": 1, + "sourceStart": 0, + "sourceLength": 70 + } + ], + "effectiveArguments": [ + { + "clauseElementIndex": 1, + "value": { + "kind": "Unknown", + "values": [], + "pattern": null, + "coveringDirectory": null + } + } + ], + "workingDirectory": { + "kind": "Exact", + "values": [ + "C:/work" + ], + "pattern": null, + "coveringDirectory": null + } + }, + { + "clauseIndex": 2, + "immediateRole": "ExecutionRegion", + "isComplete": false, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 70 + }, + { + "ancestorKind": "CommandList", + "region": "Statement", + "childIndex": 1, + "sourceStart": 0, + "sourceLength": 70 + }, + { + "ancestorKind": "ExecutionRegion", + "region": "ExecutionRegion", + "childIndex": 0, + "sourceStart": 44, + "sourceLength": 26 + }, + { + "ancestorKind": "Block", + "region": "Statement", + "childIndex": 0, + "sourceStart": 45, + "sourceLength": 24 + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Unknown", + "values": [], + "pattern": null, + "coveringDirectory": null + } + } + ] + }, + "notes": "A mutation of an alias canonical target also invalidates the authored alias receiver and keeps the nested command visible." +} diff --git a/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/powershell.json b/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/powershell.json index e3ae8cf..2bd91ec 100644 --- a/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/powershell.json +++ b/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/powershell.json @@ -550,28 +550,30 @@ }, { "id": "pwsh-ordinary-scriptblock-argument", - "concern": "Executable-specific script-block data remains opaque", + "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", + "compatibilityProjectionLanded": true, + "concern": "Proved local Invoke-Command exposes its positional script block as executable", "input": "Invoke-Command { Remove-Item secret.txt }", "current": { "isUnparseable": false }, "desired": { "syntax": [ { "id": "root", "kind": "Block", "slot": "Root" }, { "id": "invoke", "kind": "SimpleCommand", "parent": "root", "slot": "Statement", "commandIndex": 0 }, - { "id": "scriptBlock", "kind": "OpaqueArgument", "parent": "invoke", "slot": "Argument" } + { "id": "region", "kind": "ExecutionRegion", "parent": "invoke", "slot": "ExecutionRegion", "hostClauseElementIndex": 1, "executionOrigin": "CommandArgument", "executionPhase": "Main", "executionTiming": "Synchronous", "executionCardinality": "Once" }, + { "id": "body", "kind": "Block", "parent": "region", "slot": "Body" }, + { "id": "remove", "kind": "SimpleCommand", "parent": "body", "slot": "Statement", "commandIndex": 1 } ], "commands": [ { "authoredVerb": "Invoke-Command", "immediateRole": "Ordinary", "ancestry": ["root"], - "isComplete": true, - "effectiveValues": [ - { "sourceElement": "{ Remove-Item secret.txt }", "kind": "Unknown", "isPolicySensitive": true } - ] - } + "isComplete": true + }, + { "authoredVerb": "Remove-Item", "immediateRole": "ExecutionRegion", "ancestry": ["root", "region", "body"], "isComplete": true } ], - "compatibility": { "verbs": ["Invoke-Command"], "preservesAuthoredDynamicValues": true }, - "securityInvariants": ["OpaqueDataNotExecuted", "UnknownPolicyValueFailsClosed"] + "compatibility": { "verbs": ["Invoke-Command", "Remove-Item"], "preservesAuthoredDynamicValues": true }, + "securityInvariants": ["AllCommandsVisible"] } }, { @@ -2189,6 +2191,7 @@ }, { "id": "pwsh-script-block-proved-data", + "compatibilityProjectionLanded": true, "concern": "Canonical Write-Output consumes a script block as data rather than executing its contents", "input": "Write-Output { Remove-Item target.txt }", "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", @@ -2204,10 +2207,12 @@ ], "compatibility": { "verbs": ["Write-Output"], "preservesAuthoredDynamicValues": true }, "securityInvariants": ["AllCommandsVisible", "OpaqueDataNotExecuted"] - } + }, + "notes": "The same classification for a module-qualified spelling still requires a constrained baseline and bounded proof that no exact alias shadowed that authored name." }, { "id": "pwsh-script-block-unknown-receiver", + "compatibilityProjectionLanded": true, "concern": "Unknown receiver over-approximates a script-block argument as executable", "input": "Invoke-Custom { Remove-Item target.txt }", "current": { "isUnparseable": false }, diff --git a/tests/ShellSyntaxTree.Tests/Parsing/PwshExecutionRegionBindingCatalogTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/PwshExecutionRegionBindingCatalogTests.cs index fd6995a..23c3646 100644 --- a/tests/ShellSyntaxTree.Tests/Parsing/PwshExecutionRegionBindingCatalogTests.cs +++ b/tests/ShellSyntaxTree.Tests/Parsing/PwshExecutionRegionBindingCatalogTests.cs @@ -18,6 +18,13 @@ public class PwshExecutionRegionBindingCatalogTests WorkingDirectory = "C:/work", }); + private static readonly PwshParser IsolatedParser = new(new PwshParserOptions + { + HomeDirectory = "C:/Users/user", + WorkingDirectory = "C:/work", + InitialStateMode = PwshInitialStateMode.IsolatedNonInteractiveNoProfile, + }); + [Theory] [InlineData("% { Get-Date }", "ForEachObject", "ForEachScriptBlock", ExecutionRegionPhase.Process, @@ -90,16 +97,24 @@ public void Static_command_spellings_share_the_canonical_receiver(string source) } [Fact] - public void Module_qualified_receiver_is_admitted_once_body_emission_is_guaranteed() + public void Module_qualified_catalog_lookup_is_separate_from_identity_proof() { var resolved = PwshExecutionRegionBindingCatalog.TryResolveStaticCommandName( "Microsoft.PowerShell.Core\\ForEach-Object", out var canonical); - var parsed = Parser.Parse( + var conservative = Parser.Parse( + "Microsoft.PowerShell.Core\\ForEach-Object { Remove-Item victim.txt }"); + var parsed = IsolatedParser.Parse( "Microsoft.PowerShell.Core\\ForEach-Object { Remove-Item victim.txt }"); Assert.True(resolved); Assert.Equal("ForEach-Object", canonical); + Assert.Equal( + ExecutionRegionPhase.Unknown, + Assert.Single( + Assert.IsType( + Assert.Single(conservative.Syntax.Statements)) + .ExecutionRegions).Phase); Assert.False(parsed.IsUnparseable); Assert.Equal(2, parsed.Commands.Count); Assert.Equal(2, parsed.Clauses.Count); diff --git a/tests/ShellSyntaxTree.Tests/Parsing/PwshExecutionRegionStructuralTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/PwshExecutionRegionStructuralTests.cs index 67fe7e8..60c2b3c 100644 --- a/tests/ShellSyntaxTree.Tests/Parsing/PwshExecutionRegionStructuralTests.cs +++ b/tests/ShellSyntaxTree.Tests/Parsing/PwshExecutionRegionStructuralTests.cs @@ -515,6 +515,23 @@ public void Parallel_child_runspace_does_not_inherit_host_alias_mutation() Assert.True(result.Commands.Last().IsComplete); } + [Fact] + public void Parallel_child_runspace_reestablishes_proved_data_receivers() + { + var result = ParseIsolated( + "Set-Alias Write-Output Invoke-Command; 1 | " + + "Microsoft.PowerShell.Core\\ForEach-Object -Parallel { " + + "Write-Output { Remove-Item victim.txt } }"); + + Assert.DoesNotContain( + result.Commands, + command => command.Clause.Verb.Tokens[0] == "Remove-Item"); + var childWrite = Assert.Single( + result.Commands, + command => command.Clause.Verb.Tokens[0] == "Write-Output"); + Assert.True(childWrite.IsComplete); + } + [Fact] public void Parallel_pooled_runspace_command_mutation_joins_later_activations() { @@ -997,7 +1014,7 @@ public void Observed_command_mutation_downgrades_a_later_receiver() } [Fact] - public void Module_qualified_receiver_remains_proved_after_alias_mutation() + public void Module_qualified_receiver_remains_proved_after_unrelated_alias_mutation() { var result = ParseIsolated( "Set-Alias Measure-Command Write-Output; " + @@ -1015,7 +1032,7 @@ public void Module_qualified_receiver_remains_proved_after_alias_mutation() Assert.Equal(ExecutionRegionTiming.Synchronous, region.Timing); Assert.Equal(ExecutionRegionCardinality.Once, region.Cardinality); Assert.True(result.Commands[1].IsComplete); - Assert.False(result.Commands[2].IsComplete); + Assert.True(result.Commands[2].IsComplete); } [Fact] @@ -1052,7 +1069,7 @@ public void Multiple_script_blocks_keep_authored_regions_and_host_coordinates() "ForEach-Object -Begin { Write-Output begin } " + "-Process { Remove-Item one }, { Remove-Item two }"; - var result = Parse(source); + var result = ParseIsolated(source); var host = Assert.IsType(Assert.Single(result.Syntax.Statements)); Assert.Equal(3, host.ExecutionRegions.Count); @@ -1087,6 +1104,87 @@ public void Multiple_script_blocks_keep_authored_regions_and_host_coordinates() Assert.Equal(4, result.Commands.Count); } + [Theory] + [InlineData("Write-Output { Remove-Item victim.txt }")] + [InlineData("echo { Remove-Item victim.txt }")] + [InlineData( + "Microsoft.PowerShell.Utility\\Write-Output { Remove-Item victim.txt }")] + public void Proved_data_receiver_keeps_script_block_opaque(string source) + { + var result = ParseIsolated(source); + + var host = Assert.IsType(Assert.Single(result.Syntax.Statements)); + Assert.Empty(host.ExecutionRegions); + Assert.Equal(ArgKind.DynamicSkip, Assert.Single(host.Clause.Args).Kind); + Assert.Equal("{ Remove-Item victim.txt }", host.Clause.Elements[1].Raw); + Assert.True(Assert.Single(result.Commands).IsComplete); + Assert.Single(result.Clauses); + } + + [Fact] + public void Module_qualified_data_receiver_requires_a_constrained_baseline() + { + var result = Parse( + "Microsoft.PowerShell.Utility\\Write-Output { Remove-Item victim.txt }"); + + var host = Assert.IsType(Assert.Single(result.Syntax.Statements)); + Assert.Single(host.ExecutionRegions); + Assert.Equal(2, result.Commands.Count); + Assert.All(result.Commands, command => Assert.False(command.IsComplete)); + } + + [Fact] + public void Observed_receiver_mutation_prevents_a_false_data_proof() + { + var result = ParseIsolated( + "Set-Alias Write-Output Invoke-Command; " + + "Write-Output { Remove-Item victim.txt }"); + + var list = Assert.IsType(Assert.Single(result.Syntax.Statements)); + var host = Assert.IsType(list.Items[1].Command); + var region = Assert.Single(host.ExecutionRegions); + Assert.Equal(ExecutionRegionPhase.Unknown, region.Phase); + Assert.Equal( + new[] { "Set-Alias", "Write-Output", "Remove-Item" }, + result.Commands.Select(command => command.Clause.Verb.Tokens[0])); + Assert.True(result.Commands[0].IsComplete); + Assert.All(result.Commands.Skip(1), command => Assert.False(command.IsComplete)); + } + + [Fact] + public void Exact_module_qualified_alias_prevents_a_false_data_proof() + { + var result = ParseIsolated( + "Set-Alias 'Microsoft.PowerShell.Utility\\Write-Output' " + + "Invoke-Command; Microsoft.PowerShell.Utility\\Write-Output " + + "{ Remove-Item victim.txt }"); + + Assert.Equal( + new[] + { + "Set-Alias", + "Microsoft.PowerShell.Utility\\Write-Output", + "Remove-Item", + }, + result.Commands.Select(command => command.Clause.Verb.Tokens[0])); + Assert.True(result.Commands[0].IsComplete); + Assert.All(result.Commands.Skip(1), command => Assert.False(command.IsComplete)); + } + + [Fact] + public void Canonical_alias_target_mutation_prevents_a_false_data_proof() + { + var result = ParseIsolated( + "Set-Alias Write-Output Invoke-Command; " + + "echo { Remove-Item victim.txt }"); + + Assert.Equal( + new[] { "Set-Alias", "echo", "Remove-Item" }, + result.Commands.Select(command => command.Clause.Verb.Tokens[0])); + Assert.True(result.Commands[0].IsComplete); + Assert.All(result.Commands.Skip(1), command => Assert.False(command.IsComplete)); + } + [Theory] [InlineData("Write-Output { Remove-Item victim.txt }")] [InlineData("Invoke-Custom { Remove-Item victim.txt }")] @@ -1105,7 +1203,7 @@ public void Unproved_receiver_identity_never_hides_a_script_block(string source) [InlineData("ForEach-Object { $_ }")] public void Pure_output_expressions_do_not_invent_command_occurrences(string source) { - var result = Parse(source); + var result = ParseIsolated(source); var host = Assert.IsType(Assert.Single(result.Syntax.Statements)); Assert.Empty(Assert.Single(host.ExecutionRegions).Body.Statements); @@ -1144,7 +1242,7 @@ public void Unknown_region_poisons_later_cwd_and_command_resolution_facts() { var location = Parse( "Invoke-Custom { Set-Location /tmp }; Remove-Item relative.txt"); - var alias = Parse( + var alias = ParseIsolated( "ForEach-Object { Set-Alias ri Write-Output }; ri victim.txt"); Assert.Equal( diff --git a/tools/PwshCorpusTool/CorpusManifest.cs b/tools/PwshCorpusTool/CorpusManifest.cs index 2d36f47..d761608 100644 --- a/tools/PwshCorpusTool/CorpusManifest.cs +++ b/tools/PwshCorpusTool/CorpusManifest.cs @@ -101,6 +101,17 @@ private static ManifestEntry VIE(string slug, string input, string notes) => PowerShellInitialStateMode: PwshInitialStateMode.IsolatedNonInteractiveNoProfile); + private static ManifestEntry VE(string slug, string input, string notes) => + new( + slug, + input, + notes, + false, + ManifestTransform.None, + IncludeElements: true, + IncludeStructure: true, + IncludeV03Assertions: true); + private static ManifestEntry A(string slug, string name, string input, string notes) => new( slug, @@ -1094,5 +1105,22 @@ private static string NestIex(string inner, int depth) "foreach ($f in @('one.txt', 'two.txt')) { pwsh -EncodedCommand " + B64("pwsh -EncodedCommand " + B64("Get-Date")) + " > $f }", "Nested encoded wrappers retain the invocation scope that owns the outer redirect."), + VI("v03_script_block_proved_data", + "Write-Output { Remove-Item target.txt }", + "A proved canonical data receiver keeps a script block opaque instead of inventing a Remove-Item command occurrence."), + VE("v03_script_block_unknown_receiver", + "Invoke-Custom { Remove-Item target.txt }", + "An unproved receiver over-approximates the script block as an incomplete execution region so its nested command remains visible."), + VIE("v03_invoke_command_local_region", + "Invoke-Command { Remove-Item secret.txt }", + "A proved local Invoke-Command receiver exposes its positional script block as one synchronous execution region."), + VIE("v03_module_qualified_data_exact_alias", + "Set-Alias 'Microsoft.PowerShell.Utility\\Write-Output' Invoke-Command; " + + "Microsoft.PowerShell.Utility\\Write-Output { Remove-Item victim.txt }", + "An exact alias can shadow a module-qualified-looking token, so the nested command remains visible and incomplete."), + VIE("v03_data_receiver_canonical_alias_mutation", + "Set-Alias Write-Output Invoke-Command; " + + "echo { Remove-Item victim.txt }", + "A mutation of an alias canonical target also invalidates the authored alias receiver and keeps the nested command visible."), }; }