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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions Tools/windows/IsolatedProcessEnvironment.ps1
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
215 changes: 215 additions & 0 deletions Tools/windows/Tests/IsolatedProcessEnvironment.Contracts.Tests.ps1
Original file line number Diff line number Diff line change
@@ -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.
}
1 change: 1 addition & 0 deletions Tools/windows/Tests/ValidationRunner.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
44 changes: 44 additions & 0 deletions Tools/windows/validation-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand All @@ -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 <base>..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
Expand Down
Loading