diff --git a/Tools/windows/IsolatedProcessEnvironment.ps1 b/Tools/windows/IsolatedProcessEnvironment.ps1 new file mode 100644 index 00000000..9ca6b764 --- /dev/null +++ b/Tools/windows/IsolatedProcessEnvironment.ps1 @@ -0,0 +1,81 @@ +$ErrorActionPreference = "Stop" + +function Get-IsolatedExpectedEnvironment { + [CmdletBinding()] + param([Parameter(Mandatory)] [pscustomobject] $Expected) + + $names = @( + "SystemRoot", "WINDIR", "SystemDrive", "COMSPEC", "PATHEXT", "PATH", "PSModulePath", + "TEMP", "TMP", "HOME", "USERPROFILE", "LOCALAPPDATA", "APPDATA", "DOTNET_ROOT", + "DOTNET_ROOT_X64", "DOTNET_MULTILEVEL_LOOKUP", "DOTNET_ROLL_FORWARD", "DOTNET_CLI_HOME", + "DOTNET_SKIP_FIRST_TIME_EXPERIENCE", "DOTNET_CLI_TELEMETRY_OPTOUT", "DOTNET_NOLOGO", + "DOTNET_BUNDLE_EXTRACT_BASE_DIR", "NUGET_PACKAGES", "NUGET_HTTP_CACHE_PATH", + "NUGET_PLUGINS_CACHE_PATH", "PSModuleAnalysisCachePath", "PS_DISABLE_MODULE_ANALYSIS_CACHE", + "POWERSHELL_TELEMETRY_OPTOUT", "XDG_CACHE_HOME", "MSBUILDUSEREXTENSIONSPATH" + ) + $properties = @($Expected.PSObject.Properties) + if ($properties.Count -ne 30) { throw "ISOLATED_ENV_EXPECTED_KEYS" } + $values = [Collections.Generic.Dictionary[string,string]]::new([StringComparer]::Ordinal) + foreach ($property in $properties) { + if ($property.MemberType -ne [Management.Automation.PSMemberTypes]::NoteProperty -or + $property.Name -cnotin $names -or $property.Value -isnot [string] -or + [string]::IsNullOrWhiteSpace($property.Value) -or $property.Value.Contains([char]0)) { + throw "ISOLATED_ENV_EXPECTED_LITERAL" + } + $values.Add($property.Name, $property.Value) + } + foreach ($name in $names) { + if (-not $values.ContainsKey($name)) { throw "ISOLATED_ENV_EXPECTED_KEYS" } + } + return ,$values +} + +function Initialize-IsolatedProcessEnvironment { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [pscustomobject] $Expected, + [Parameter(Mandatory)] [string] $EntryScriptPath, + [Parameter(Mandatory)] [string] $ApprovedEntryScriptPath + ) + + $values = Get-IsolatedExpectedEnvironment -Expected $Expected + if (-not [IO.Path]::IsPathFullyQualified($EntryScriptPath) -or + -not [IO.Path]::IsPathFullyQualified($ApprovedEntryScriptPath) -or + -not [string]::Equals([IO.Path]::GetFullPath($EntryScriptPath), + [IO.Path]::GetFullPath($ApprovedEntryScriptPath), [StringComparison]::OrdinalIgnoreCase) -or + -not [IO.File]::Exists($EntryScriptPath)) { + throw "ISOLATED_ENV_ENTRY_PATH" + } + + # pwsh constructs PSModulePath at startup; only these two reviewed literals are reasserted. + try { + [Environment]::SetEnvironmentVariable("PATHEXT", $values["PATHEXT"], [EnvironmentVariableTarget]::Process) + [Environment]::SetEnvironmentVariable("PSModulePath", $values["PSModulePath"], [EnvironmentVariableTarget]::Process) + } catch { + throw "ISOLATED_ENV_PROCESS_WRITE" + } +} + +function Assert-IsolatedProcessEnvironment { + [CmdletBinding()] + param([Parameter(Mandatory)] [pscustomobject] $Expected) + + $values = Get-IsolatedExpectedEnvironment -Expected $Expected + $actual = [Environment]::GetEnvironmentVariables([EnvironmentVariableTarget]::Process) + $expectedNames = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach ($name in $values.Keys) { $null = $expectedNames.Add($name) } + $names = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach ($name in $actual.Keys) { + if (-not $expectedNames.Contains([string]$name)) { throw "ISOLATED_ENV_UNEXPECTED_KEY" } + $null = $names.Add([string]$name) + } + if ($actual.Count -ne 30 -or $names.Count -ne 30) { throw "ISOLATED_ENV_KEY_SET" } + foreach ($entry in $values.GetEnumerator()) { + if (-not $names.Contains($entry.Key) -or + -not [string]::Equals( + [Environment]::GetEnvironmentVariable($entry.Key, [EnvironmentVariableTarget]::Process), + $entry.Value, [StringComparison]::Ordinal)) { + throw "ISOLATED_ENV_LITERAL" + } + } +} diff --git a/Tools/windows/Tests/IsolatedProcessEnvironment.Contracts.Tests.ps1 b/Tools/windows/Tests/IsolatedProcessEnvironment.Contracts.Tests.ps1 new file mode 100644 index 00000000..d1464dec --- /dev/null +++ b/Tools/windows/Tests/IsolatedProcessEnvironment.Contracts.Tests.ps1 @@ -0,0 +1,215 @@ +[CmdletBinding()] +param( + [ValidateSet("RED", "GREEN", "REGRESSION")] [string] $Phase = "REGRESSION", + [ValidateSet("", "baseline", "green", "missing-pathext", "missing-modulepath", "empty-pathext", + "empty-modulepath", "missing-other-literal", "extra-key", "other-key-drift", + "post-reassertion-mutation", "wrong-path", "scope-user", "scope-machine", "scope-host")] + [string] $ChildCase = "", + [string] $ExpectedPath = "" +) + +$ErrorActionPreference = "Stop" +$helperPath = Join-Path $PSScriptRoot "..\IsolatedProcessEnvironment.ps1" +if ($ChildCase) { + $expected = Get-Content -LiteralPath $ExpectedPath -Raw | ConvertFrom-Json + . $helperPath + $beforePathext = [Environment]::GetEnvironmentVariable("PATHEXT", "Process") + $beforeModules = [Environment]::GetEnvironmentVariable("PSModulePath", "Process") + $startupDrift = -not [string]::Equals($beforePathext, $expected.PATHEXT, [StringComparison]::Ordinal) -or + -not [string]::Equals($beforeModules, $expected.PSModulePath, [StringComparison]::Ordinal) + $refused = $false + $errorCode = $null + try { + switch ($ChildCase) { + "missing-pathext" { $expected.PSObject.Properties.Remove("PATHEXT") } + "missing-modulepath" { $expected.PSObject.Properties.Remove("PSModulePath") } + "empty-pathext" { $expected.PATHEXT = "" } + "empty-modulepath" { $expected.PSModulePath = "" } + "missing-other-literal" { $expected.PSObject.Properties.Remove("DOTNET_NOLOGO") } + "extra-key" { [Environment]::SetEnvironmentVariable("ISOLATED_TEST_EXTRA", "fixture", "Process") } + "other-key-drift" { [Environment]::SetEnvironmentVariable("DOTNET_NOLOGO", "fixture-drift", "Process") } + } + $parameters = @{ + Expected = $expected + EntryScriptPath = $PSCommandPath + ApprovedEntryScriptPath = $PSCommandPath + } + if ($ChildCase -eq "wrong-path") { $parameters.ApprovedEntryScriptPath += ".wrong" } + if ($ChildCase -in @("scope-user", "scope-machine", "scope-host")) { + $parameters.Target = $ChildCase.Substring(6) + } + if ($ChildCase -ne "baseline") { Initialize-IsolatedProcessEnvironment @parameters } + if ($ChildCase -eq "post-reassertion-mutation") { + [Environment]::SetEnvironmentVariable("PSModulePath", "fixture-mutation", "Process") + } + Assert-IsolatedProcessEnvironment -Expected $expected + } catch { + $refused = $true + $errorCode = if ($_.Exception.Message -cmatch '^ISOLATED_ENV_[A-Z_]+$') { + $_.Exception.Message + } elseif ($_.Exception -is [Management.Automation.ParameterBindingException]) { + "ISOLATED_ENV_PARAMETER_BINDING" + } else { "ISOLATED_ENV_UNEXPECTED_FAILURE" } + } + $untouched = [string]::Equals($beforePathext, + [Environment]::GetEnvironmentVariable("PATHEXT", "Process"), [StringComparison]::Ordinal) -and + [string]::Equals($beforeModules, + [Environment]::GetEnvironmentVariable("PSModulePath", "Process"), [StringComparison]::Ordinal) + [Console]::Out.WriteLine(([ordered]@{ + startupDrift = [bool]$startupDrift + refused = [bool]$refused + errorCode = $errorCode + twoLiteralsUntouched = [bool]$untouched + } | ConvertTo-Json -Compress)) + if ($refused) { exit 1 } + exit 0 +} + +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..\..")).Path +$scratch = Join-Path $repoRoot (".build\isolated-process-environment\" + [guid]::NewGuid().ToString("N")) +$null = New-Item -ItemType Directory -Path $scratch +$pwsh = [Environment]::ProcessPath +$hostPathext = [Environment]::GetEnvironmentVariable("PATHEXT", "Process") +$hostModules = [Environment]::GetEnvironmentVariable("PSModulePath", "Process") +$executed = 0 + +function Assert-Contract([bool] $Condition, [string] $Code) { + if (-not $Condition) { throw "Isolated process environment contract: $Code" } +} + +function Invoke-EnvironmentCase([string] $Name) { + $caseRoot = Join-Path $scratch $Name + foreach ($suffix in @("", "temp", "profile", "modules", "cache", "cache\local", "cache\roaming", + "cache\dotnet", "cache\bundle", "cache\nuget-packages", "cache\nuget-http", + "cache\nuget-plugins", "cache\xdg", "cache\msbuild")) { + $path = if ($suffix) { Join-Path $caseRoot $suffix } else { $caseRoot } + $null = New-Item -ItemType Directory -Path $path + } + $windows = [Environment]::GetFolderPath([Environment+SpecialFolder]::Windows) + $system = Join-Path $windows "System32" + $expected = [ordered]@{ + SystemRoot = $windows; WINDIR = $windows; SystemDrive = [IO.Path]::GetPathRoot($windows).TrimEnd('\') + COMSPEC = (Join-Path $system "cmd.exe"); PATHEXT = ".COM;.EXE;.BAT;.CMD" + PATH = "$PSHOME;$system"; PSModulePath = (Join-Path $caseRoot "modules") + TEMP = (Join-Path $caseRoot "temp"); TMP = (Join-Path $caseRoot "temp") + HOME = (Join-Path $caseRoot "profile"); USERPROFILE = (Join-Path $caseRoot "profile") + LOCALAPPDATA = (Join-Path $caseRoot "cache\local"); APPDATA = (Join-Path $caseRoot "cache\roaming") + DOTNET_ROOT = $PSHOME; DOTNET_ROOT_X64 = $PSHOME; DOTNET_MULTILEVEL_LOOKUP = "0" + DOTNET_ROLL_FORWARD = "Disable"; DOTNET_CLI_HOME = (Join-Path $caseRoot "cache\dotnet") + DOTNET_SKIP_FIRST_TIME_EXPERIENCE = "1"; DOTNET_CLI_TELEMETRY_OPTOUT = "1"; DOTNET_NOLOGO = "1" + DOTNET_BUNDLE_EXTRACT_BASE_DIR = (Join-Path $caseRoot "cache\bundle") + NUGET_PACKAGES = (Join-Path $caseRoot "cache\nuget-packages") + NUGET_HTTP_CACHE_PATH = (Join-Path $caseRoot "cache\nuget-http") + NUGET_PLUGINS_CACHE_PATH = (Join-Path $caseRoot "cache\nuget-plugins") + PSModuleAnalysisCachePath = (Join-Path $caseRoot "cache\module-analysis") + PS_DISABLE_MODULE_ANALYSIS_CACHE = "1"; POWERSHELL_TELEMETRY_OPTOUT = "1" + XDG_CACHE_HOME = (Join-Path $caseRoot "cache\xdg") + MSBUILDUSEREXTENSIONSPATH = (Join-Path $caseRoot "cache\msbuild") + } + $mapPath = Join-Path $caseRoot "expected.json" + [IO.File]::WriteAllText($mapPath, ($expected | ConvertTo-Json), [Text.UTF8Encoding]::new($false)) + $start = [Diagnostics.ProcessStartInfo]::new($pwsh) + $start.UseShellExecute = $false + $start.RedirectStandardOutput = $true + $start.RedirectStandardError = $true + $start.WorkingDirectory = $caseRoot + $start.Environment.Clear() + foreach ($key in $expected.Keys) { $start.Environment.Add($key, $expected[$key]) } + foreach ($arg in @("-NoProfile", "-File", $PSCommandPath, "-ChildCase", $Name, "-ExpectedPath", $mapPath)) { + $start.ArgumentList.Add($arg) + } + $process = [Diagnostics.Process]::new() + $process.StartInfo = $start + $stdout = [IO.File]::Open((Join-Path $caseRoot "stdout.raw.log"), "CreateNew", "Write", "Read") + $stderr = [IO.File]::Open((Join-Path $caseRoot "stderr.raw.log"), "CreateNew", "Write", "Read") + $held = $null + $started = $false + $stopAttempted = $false + try { + $started = $process.Start() + Assert-Contract $started "CHILD_START" + $held = $process.SafeHandle + $pidOwned = $process.Id + $outTask = $process.StandardOutput.BaseStream.CopyToAsync($stdout) + $errTask = $process.StandardError.BaseStream.CopyToAsync($stderr) + if (-not $process.WaitForExit(30000)) { + if ($held.IsInvalid -or $held.IsClosed -or $process.Id -ne $pidOwned) { throw "No held test-child identity" } + $stopAttempted = $true + Stop-Process -Id $pidOwned -Force -ErrorAction Stop + $null = $process.WaitForExit(5000) + throw "Isolated process environment contract: CHILD_DEADLINE" + } + $null = $outTask.GetAwaiter().GetResult() + $null = $errTask.GetAwaiter().GetResult() + $exitCode = $process.ExitCode + } finally { + if ($started -and -not $process.HasExited -and -not $stopAttempted) { + if ($null -eq $held -or $held.IsInvalid -or $held.IsClosed -or $process.Id -ne $pidOwned) { + throw "No held test-child identity during finalization" + } + $stopAttempted = $true + Stop-Process -Id $pidOwned -Force -ErrorAction Stop + $null = $process.WaitForExit(5000) + } + $stdout.Dispose(); $stderr.Dispose(); $process.Dispose() + } + $text = [IO.File]::ReadAllText((Join-Path $caseRoot "stdout.raw.log")) + $result = $text | ConvertFrom-Json + Assert-Contract ((Get-Item -LiteralPath (Join-Path $caseRoot "stderr.raw.log")).Length -eq 0) "CHILD_STDERR" + Assert-Contract ($result.startupDrift -is [bool] -and $result.refused -is [bool]) "CHILD_RESULT" + return [pscustomobject]@{ ExitCode = $exitCode; Result = $result } +} + +try { + $cases = if ($Phase -eq "RED") { @("baseline") } elseif ($Phase -eq "GREEN") { @("green") } else { + @("baseline", "green", "missing-pathext", "missing-modulepath", "empty-pathext", "empty-modulepath", + "missing-other-literal", "extra-key", "other-key-drift", "post-reassertion-mutation", + "wrong-path", "scope-user", "scope-machine", "scope-host") + } + foreach ($case in $cases) { + $actual = Invoke-EnvironmentCase $case + if ($case -eq "green") { + Assert-Contract ($actual.ExitCode -eq 0 -and -not $actual.Result.refused) "GREEN" + Assert-Contract $actual.Result.startupDrift "GREEN_MUST_EXERCISE_STARTUP_DRIFT" + } else { + Assert-Contract ($actual.ExitCode -ne 0 -and $actual.Result.refused) "EXPECTED_REFUSAL" + $expectedCode = switch ($case) { + "baseline" { "ISOLATED_ENV_LITERAL" } + { $_ -in @("missing-pathext", "missing-modulepath", "missing-other-literal") } { "ISOLATED_ENV_EXPECTED_KEYS" } + { $_ -in @("empty-pathext", "empty-modulepath") } { "ISOLATED_ENV_EXPECTED_LITERAL" } + "extra-key" { "ISOLATED_ENV_UNEXPECTED_KEY" } + { $_ -in @("other-key-drift", "post-reassertion-mutation") } { "ISOLATED_ENV_LITERAL" } + "wrong-path" { "ISOLATED_ENV_ENTRY_PATH" } + default { "ISOLATED_ENV_PARAMETER_BINDING" } + } + Assert-Contract ($actual.Result.errorCode -ceq $expectedCode) "EXACT_REFUSAL" + if ($case -eq "baseline") { Assert-Contract $actual.Result.startupDrift "RED_MUST_EXERCISE_STARTUP_DRIFT" } + if ($case -in @("wrong-path", "missing-pathext", "missing-modulepath", "empty-pathext", + "empty-modulepath", "missing-other-literal", "scope-user", "scope-machine", "scope-host")) { + Assert-Contract $actual.Result.twoLiteralsUntouched "REFUSAL_BEFORE_WRITES" + } + } + $executed++ + } + $errors = $null + $ast = [Management.Automation.Language.Parser]::ParseFile($helperPath, [ref]$null, [ref]$errors) + Assert-Contract (@($errors).Count -eq 0) "HELPER_PARSE" + $writes = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.InvokeMemberExpressionAst] -and $node.Member.Value -eq "SetEnvironmentVariable" + }, $true)) + Assert-Contract ($writes.Count -eq 2) "ONLY_TWO_WRITE_SITES" + foreach ($write in $writes) { + Assert-Contract ($write.Arguments.Count -eq 3 -and + $write.Arguments[2].Extent.Text -ceq "[EnvironmentVariableTarget]::Process") "NO_HOST_USER_MACHINE_WRITE" + } + Assert-Contract ([string]::Equals($hostPathext, + [Environment]::GetEnvironmentVariable("PATHEXT", "Process"), [StringComparison]::Ordinal)) "HOST_PATHEXT_UNCHANGED" + Assert-Contract ([string]::Equals($hostModules, + [Environment]::GetEnvironmentVariable("PSModulePath", "Process"), [StringComparison]::Ordinal)) "HOST_MODULES_UNCHANGED" + Assert-Contract ($executed -gt 0) "POSITIVE_CASE_COUNT" + Write-Output "Isolated process environment contracts: $executed cases; phase=$Phase" + if ($Phase -eq "RED") { throw "RED: actual fresh-pwsh literal guard rejected startup drift without reassertion" } +} finally { + # Retain exact child streams and synthetic maps for later evidence review. +} diff --git a/Tools/windows/Tests/ValidationRunner.Tests.ps1 b/Tools/windows/Tests/ValidationRunner.Tests.ps1 index feb299ae..02898608 100644 --- a/Tools/windows/Tests/ValidationRunner.Tests.ps1 +++ b/Tools/windows/Tests/ValidationRunner.Tests.ps1 @@ -3356,6 +3356,7 @@ function Test-ZigResolverDiagnostics([string] $source) { } & (Join-Path $PSScriptRoot "PesterVersion.Tests.ps1") +& (Join-Path $PSScriptRoot "IsolatedProcessEnvironment.Contracts.Tests.ps1") $runner = Join-Path $PSScriptRoot "..\validate.ps1" if (-not (Test-Path $runner)) { diff --git a/Tools/windows/validation-matrix.md b/Tools/windows/validation-matrix.md index 09bfb4fb..0594bf77 100644 --- a/Tools/windows/validation-matrix.md +++ b/Tools/windows/validation-matrix.md @@ -8,6 +8,7 @@ The Windows port must have runnable commands before implementation fleets begin. |---|---| | Windows investigation spikes | `pwsh Tools/windows/validate.ps1 -Task all` | | Validation runner contract | `pwsh Tools/windows/Tests/ValidationRunner.Tests.ps1` | +| Isolated 30-key process environment boundary | `pwsh -NoProfile -File Tools\windows\Tests\IsolatedProcessEnvironment.Contracts.Tests.ps1 -Phase REGRESSION` | | Deterministic visual baseline | `pwsh Tools/windows/validate.ps1 -Task visual-baseline` | | TDD evidence contract | `pwsh Tools/tdd/Tests/TddEvidence.Tests.ps1` | | Platform/wire contracts | `pwsh Tools/windows/validate.ps1 -Task swift-contracts` | @@ -34,6 +35,49 @@ The Windows port must have runnable commands before implementation fleets begin. | macOS format/lint | `make check` | | DCO commit range | `git log --format=%B ..HEAD` plus trailer validation | +### Isolated process environment boundary + +At isolated preflight/reader entry, `IsolatedProcessEnvironment.ps1` reasserts +only `PATHEXT` and `PSModulePath`, using the reviewed expected map and explicit +Process scope. It does not read User/Machine environment values, merge paths, +invent defaults, or write the parent process. The entry validates its approved +script path first. The full 30-key literal guard remains required, including +exact key membership and zero unexpected keys; later mutation is still refused. + +The contract source provides `-Phase RED` (fresh-pwsh startup plus the old guard, +deliberately failing), `-Phase GREEN` (same startup plus process-local reassertion) +and `-Phase REGRESSION` (14 planned cases). Negatives cover missing/empty expected +literals, extra keys, other-key drift, post-reassertion mutation, wrong entry +path and refused User/Machine/Host target arguments. Child results contain only +fixed codes/booleans; synthetic maps and raw streams remain in owned `.build` +directories. Source preparation is not executed RED/GREEN evidence. + +Recovery supplied these official-document excerpts and revisions; no new fetch +or independent documentation verification was performed for this correction: + +- [about_PSModulePath, PowerShell 7.6](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_psmodulepath?view=powershell-7.6), + MicrosoftDocs/PowerShell-Docs commit `f435f86004c327baf231bcca0ce689f94b98b798`: + "The value of `$Env:PSModulePath` is constructed each time PowerShell starts. + The value varies by version of PowerShell and how you launched it." + "On Windows, `PSModulePath` is treated similar to how the `Path` environment + variable is treated." +- [about_Environment_Variables, PowerShell 7.6](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_environment_variables?view=powershell-7.6), + canonical commit `c4961eb5730e51304b82ed82fa663528f966c56f`: + "The Process scope contains the environment variables available in the current + process, or PowerShell session. This list of variables is inherited from the + parent process and is constructed from the variables in the Machine and User + scopes." + "When you change environment variables in PowerShell, the change affects only + the current session." + +The documentation directly supports `PSModulePath` startup construction, not +`PATHEXT` rewriting. `PATHEXT` is included because the sealed Environment11 +diagnostic observed ordinal5 unequal as well as ordinal7; neither was a +fresh-root rebind. That observation does not identify Inventory10's failing key +or a startup cause. The original Inventory10/Environment11 evidence and guards +remain immutable. Any corrected inventory entry must separately bind this +helper and preserve its existing source/IL/reference policy. + ## Required PR checks The macOS shared-regression, Windows shell, Windows port, and Windows hardening