diff --git a/Tools/windows/Invoke-UiCapture.ps1 b/Tools/windows/Invoke-UiCapture.ps1 index ffe32572..82176faf 100644 --- a/Tools/windows/Invoke-UiCapture.ps1 +++ b/Tools/windows/Invoke-UiCapture.ps1 @@ -971,7 +971,9 @@ try { $exemptWorkerPid = $null if ($membersBeforeStop -contains $workerRun.Identity.Pid) { try { - $workerSnapshot = [CapNative]::Snapshot($workerRun.Identity.Process.Handle, $workerRun.Identity.Pid) + $access = Read-CapProcessAccess -Process $workerRun.Identity.Process -Role $workerRun.Identity.Role ` + -CallSite 'Supervisor.UmbrellaWorkerSnapshot' -ExpectedPid $workerRun.Identity.Pid + $workerSnapshot = [CapNative]::Snapshot($access.Handle, $access.Pid) if (-not $workerSnapshot.IsExited -and $workerSnapshot.CreationFileTimeUtc -eq $workerRun.Identity.CreationFileTimeUtc -and $workerSnapshot.ImagePath -ieq $workerRun.Identity.ExpectedImagePath) { diff --git a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 index f7184240..87c6d1bb 100644 --- a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 +++ b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 @@ -11,6 +11,7 @@ param( [switch]$JournalObservationContractOnly, [switch]$PreflightReportContractOnly, [switch]$SelectorRefusalContractOnly, + [switch]$IdentityContractOnly, [string]$PrebuiltFixtureManifest = '', [string]$PrebuiltFixtureManifestSha256 = '', [switch]$RequireRealSeed, @@ -2699,6 +2700,204 @@ function Invoke-CapSelectorRefusalContracts { Set-CapTracePath -Path (Join-Path $scratch 'test-calls.jsonl') } +function Assert-CapIdentityUnavailable { + param([scriptblock]$Action) + try { $null = & $Action } + catch { + Assert-CapTest ($_.Exception -is [InvalidOperationException] -and + $_.Exception.Data['CapCaptureCode'] -ceq 'ProcessIdentityUnavailable') ` + "Expected typed identity unavailability, got $($_.Exception.ToString())" + return $_.Exception + } + throw 'Unavailable identity unexpectedly returned a result.' +} + +function Invoke-CapIdentityContracts { + foreach ($kind in @('not-started', 'disposed-self-wrapper')) { + Invoke-CapTest "actual $kind identity refuses unavailable Handle and Id before Snapshot binding" { + $process = if ($kind -ceq 'not-started') { [Diagnostics.Process]::new() } + else { [Diagnostics.Process]::GetProcessById($PID) } + if ($kind -ceq 'disposed-self-wrapper') { $process.Dispose() } + try { + $failure = Assert-CapIdentityUnavailable { + Get-CapProcessIdentity -Process $process -ExpectedImagePath ([Environment]::ProcessPath) -Role $kind + } + Assert-CapTest ($failure.Data['HandleState'] -ceq 'GetterException' -and + $failure.Data['IdState'] -ceq 'GetterException' -and + $null -eq $failure.Data['AcquiredPid'] -and $null -eq $failure.Data['ExpectedPid'] -and + $failure.Data['CallSite'] -ceq 'Get-CapProcessIdentity.InitialSnapshot' -and + $failure.InnerException -is [AggregateException] -and + $failure.InnerException.InnerExceptions.Count -eq 2) 'Getter failures or unknown PID evidence was lost.' + return @{ code = $failure.Data['CapCaptureCode']; details = $failure.Data + originalExceptions = @($failure.InnerException.InnerExceptions | ForEach-Object { $_.ToString() }) + historicalCause = 'UNOBSERVED; controlled object condition only' } + } finally { $process.Dispose() } + } + } + Invoke-CapTest 'synthetic accessor states reject null invalid values and independent getter failures without defaults' { + $self = [Diagnostics.Process]::GetProcessById($PID) + try { + $handle = $self.get_Handle() + $rows = @( + @{ h = $null; i = $PID; hs = 'Null'; ids = 'Available' } + @{ h = [IntPtr]::Zero; i = $PID; hs = 'Invalid'; ids = 'Available' } + @{ h = [IntPtr]::new(-1); i = $PID; hs = 'Invalid'; ids = 'Available' } + @{ h = 123; i = $PID; hs = 'Invalid'; ids = 'Available' } + @{ h = $handle; i = $null; hs = 'Available'; ids = 'Null' } + @{ h = $handle; i = 0; hs = 'Available'; ids = 'Invalid' } + @{ h = $handle; i = [string]$PID; hs = 'Available'; ids = 'Invalid' } + @{ h = $handle; i = $PID; hs = 'GetterException'; ids = 'Available' } + @{ h = $handle; i = $PID; hs = 'Available'; ids = 'GetterException' } + ) + $details = foreach ($row in $rows) { + $probe = [Diagnostics.Process]::new() + $probe | Add-Member -NotePropertyName CapAccessorProbe -NotePropertyValue @{ + row = $row; handleReads = 0; idReads = 0 + } + $probe | Add-Member -MemberType ScriptMethod -Name get_Handle -Force -Value { + $this.CapAccessorProbe.handleReads++ + if ($this.CapAccessorProbe.row.hs -ceq 'GetterException') { throw [InvalidOperationException]::new('SYNTHETIC_HANDLE_GETTER') } + return $this.CapAccessorProbe.row.h + } + $probe | Add-Member -MemberType ScriptMethod -Name get_Id -Force -Value { + $this.CapAccessorProbe.idReads++ + if ($this.CapAccessorProbe.row.ids -ceq 'GetterException') { throw [InvalidOperationException]::new('SYNTHETIC_ID_GETTER') } + return $this.CapAccessorProbe.row.i + } + try { + $failure = Assert-CapIdentityUnavailable { + Read-CapProcessAccess -Process $probe -Role 'synthetic-accessor' -CallSite 'SyntheticContract' -ExpectedPid $PID + } + Assert-CapTest ($failure.Data['HandleState'] -ceq $row.hs -and $failure.Data['IdState'] -ceq $row.ids -and + $probe.CapAccessorProbe.handleReads -eq 1 -and $probe.CapAccessorProbe.idReads -eq 1 -and + $failure.Data['ExpectedPid'] -eq $PID) 'Accessor states/counts/expected context changed.' + if ($row.ids -ceq 'Available') { + Assert-CapTest ($failure.Data['AcquiredPid'] -eq $PID) 'Actually read PID was lost when Handle was unavailable.' + } else { + Assert-CapTest ($null -eq $failure.Data['AcquiredPid']) 'Expected PID was misrepresented as acquired.' + } + if ($row.hs -ceq 'GetterException' -or $row.ids -ceq 'GetterException') { + Assert-CapTest ($null -ne $failure.InnerException) 'Original synthetic getter exception was discarded.' + } + @{ handleState = $row.hs; idState = $row.ids; handleReads = 1; idReads = 1 } + } finally { $probe.Dispose() } + } + Assert-CapTest ($details.Count -eq 9) 'Synthetic accessor state inventory changed.' + return @{ executed = $details.Count; rows = @($details); scope = 'Synthetic accessors; not a historical-cause reproduction' } + } finally { $self.Dispose() } + } + Invoke-CapTest 'valid self identity preserves actual kernel CIM generation and explicit acquisition' { + $self = [Diagnostics.Process]::GetProcessById($PID) + try { + $access = Read-CapProcessAccess -Process $self -Role 'valid-self' -CallSite 'ValidSelfContract' -ExpectedPid $PID + $snapshot = [CapNative]::Snapshot($access.Handle, $access.Pid) + $identity = Get-CapProcessIdentity -Process $self -ExpectedImagePath ([Environment]::ProcessPath) -Role 'valid-self' + $verified = Assert-CapProcessIdentity $identity + Assert-CapTest ($access.Pid -eq $PID -and $snapshot.ProcessId -eq $PID -and -not $snapshot.IsExited -and + $snapshot.CreationFileTimeUtc -eq $identity.CreationFileTimeUtc -and + $identity.InitialMetadataObserved -and $verified.State -ceq 'Live') 'Valid kernel/CIM ownership behavior changed.' + return @{ pid = $identity.Pid; creationFileTimeUtc = $identity.CreationFileTimeUtc; state = $verified.State } + } finally { $self.Dispose() } + } + Invoke-CapTest 'acquired PID mismatch and native handle PID mismatch still refuse without adoption' { + $self = [Diagnostics.Process]::GetProcessById($PID) + try { + $failure = $null + try { $null = Read-CapProcessAccess -Process $self -Role 'pid-mismatch' -CallSite 'MismatchContract' -ExpectedPid ($PID + 1) } + catch [InvalidOperationException] { $failure = $_.Exception } + Assert-CapTest ($null -ne $failure -and $failure.Data['CapCaptureCode'] -ceq 'ProcessIdentityMismatch' -and + $failure.Data['AcquiredPid'] -eq $PID -and $failure.Data['ExpectedPid'] -eq ($PID + 1)) 'Expected PID replaced the acquired PID.' + $null = Assert-CapRejects { [CapNative]::Snapshot($self.get_Handle(), ($PID + 1)) } 'Process handle/PID mismatch' + $null = Assert-CapRejects { [CapNative]::Snapshot([IntPtr]::Zero, $PID) } 'handle' + return @{ acquired = $PID; expected = $PID + 1; nativeMismatchRefused = $true; nativeZeroRefused = $true } + } finally { $self.Dispose() } + } + Invoke-CapTest 'disposed tracked identity refuses assert stop and wait rather than claiming exit' { + $self = [Diagnostics.Process]::GetProcessById($PID) + $identity = Get-CapProcessIdentity -Process $self -ExpectedImagePath ([Environment]::ProcessPath) -Role 'disposed-tracked-self' + $self.Dispose() + $failures = @( + Assert-CapIdentityUnavailable { Assert-CapProcessIdentity $identity } + Assert-CapIdentityUnavailable { Stop-CapManagedProcess $identity } + Assert-CapIdentityUnavailable { Wait-CapManagedProcess -Run ([pscustomobject]@{ Identity = $identity }) -DeadlineUtc ([DateTime]::UtcNow.AddSeconds(1)) } + ) + Assert-CapTest ($failures.Count -eq 3 -and @($failures | Where-Object { + $_.Data['ExpectedPid'] -eq $PID -and $null -eq $_.Data['AcquiredPid'] + }).Count -eq 3) 'Disposed tracked identity was accepted, stopped, or treated as gone.' + return @{ refused = 3; killAttempts = 0; exitInference = 'UNPROVEN' } + } + Invoke-CapTest 'unavailable launch rollback retains typed primary secondary pending evidence and unknown PID' { + $module = Get-Module UiCaptureDriver + $details = foreach ($knownPid in @($false, $true)) { + $process = if ($knownPid) { [Diagnostics.Process]::GetProcessById($PID) } else { [Diagnostics.Process]::new() } + $acquiredPid = if ($knownPid) { $process.get_Id() } else { $null } + $process.Dispose() + $primary = Assert-CapIdentityUnavailable { + Get-CapProcessIdentity -Process $process -ExpectedImagePath ([Environment]::ProcessPath) -Role 'unavailable-launch-contract' + } + $record = [pscustomobject]@{ + Process = $process; AcquiredPid = $acquiredPid; Role = 'unavailable-launch-contract' + ExpectedImagePath = [Environment]::ProcessPath; ExpectedParentPid = $PID; ExpectedSessionId = 0 + Snapshot = $null; StdoutTask = $null; StderrTask = $null; StartFailure = $null; RollbackFailure = $null + StartFailureException = $null; RollbackFailureException = $null + } + $outcome = & $module { + param($Record, $Primary) + $previous = $script:CapLastRollbackReceipt + $script:CapPendingLaunches.Add($Record) + try { + $outer = $null + try { Complete-CapFailedLaunch -LaunchRecord $Record -StartFailure $Primary } + catch [InvalidOperationException] { $outer = $_.Exception } + $pending = @(Get-CapPendingLaunches | Where-Object Role -CEQ 'unavailable-launch-contract') + $resolve = $null + try { $null = Resolve-CapPendingLaunches } + catch { $resolve = $_.Exception } + [pscustomobject]@{ Outer = $outer; Pending = $pending; ResolveFailure = $resolve + Receipt = $script:CapLastRollbackReceipt; StillPending = $script:CapPendingLaunches.Contains($Record) } + } finally { + $null = $script:CapPendingLaunches.Remove($Record) + $script:CapLastRollbackReceipt = $previous + } + } $record $primary + Assert-CapTest ($outcome.Outer.Data['CapCaptureCode'] -ceq 'ProcessIdentityUnavailable' -and + $outcome.Outer.InnerException -is [AggregateException] -and + $outcome.Outer.InnerException.InnerExceptions.Count -eq 2 -and + [object]::ReferenceEquals($outcome.Outer.InnerException.InnerExceptions[0], $primary) -and + $outcome.Pending.Count -eq 1 -and $null -eq $outcome.Pending[0].KernelExited -and + $outcome.Pending[0].KernelProbeFailure.Contains('ProcessIdentityUnavailable') -and + $outcome.Pending[0].KernelProbeFailureDetails['CapCaptureCode'] -ceq 'ProcessIdentityUnavailable' -and + $null -ne $outcome.ResolveFailure -and $outcome.StillPending -and + $outcome.Receipt.RefusedUnsafeTermination -and $null -eq $outcome.Receipt.KernelExited) ` + 'Unavailable cleanup lost a failure, removed pending evidence, or invented kernel exit.' + $pidText = if ($knownPid) { [string]$acquiredPid } else { 'UNAVAILABLE' } + Assert-CapTest ($outcome.Outer.Message.Contains("PID ${pidText}:") -and + $outcome.Pending[0].Pid -eq $acquiredPid -and + $outcome.ResolveFailure.Message.Contains("PID $pidText role=")) 'Failure formatting reread unavailable PID or invented zero.' + @{ acquiredPid = $acquiredPid; outer = $outcome.Outer.ToString() + pending = $outcome.Pending; kernelExit = 'UNPROVEN'; eof = 'UNPROVEN' } + } + Assert-CapTest ($details.Count -eq 2 -and @(Get-CapPendingLaunches).Count -eq 0) 'Synthetic-only pending test records were not removed.' + return @{ cases = @($details); processStarts = 0; scope = 'Actual failed-launch handler and rollback with controlled wrappers; no target' } + } +} + +if ($IdentityContractOnly) { + Invoke-CapIdentityContracts + foreach ($path in $sourceHashesBefore.Keys) { + Assert-CapTest ((Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash -ceq $sourceHashesBefore[$path]) "Source changed during identity contracts: $path" + } + $passed = @($results | Where-Object passed -EQ $true).Count + $failed = @($results | Where-Object passed -EQ $false).Count + $report = [pscustomobject]@{ suite = 'UiCaptureIdentityContracts'; executed = $results.Count; passed = $passed; failed = $failed + cases = @($results); sourceHashesBeforeImport = $sourceHashesBefore + mode = 'SELF_PROCESS_AND_SYNTHETIC_ACCESSORS_NO_CHILD_FIXTURE_OR_KILL' } + $report | ConvertTo-Json -Depth 25 | Set-Content -LiteralPath (Join-Path $scratch 'report.json') -Encoding utf8NoBOM + $report | Select-Object suite,executed,passed,failed | ConvertTo-Json -Compress + if ($results.Count -ne 7 -or $passed -ne 7 -or $failed -ne 0) { exit 1 } + exit 0 +} + if ($SelectorRefusalContractOnly) { Invoke-CapSelectorRefusalContracts $passed = @($results | Where-Object passed -EQ $true).Count @@ -2780,6 +2979,7 @@ try { } else { $cimRaceTarget = $prebuilt.CimRace if (-not $LaunchContractOnly) { + Invoke-CapIdentityContracts Invoke-CapSelectorRefusalContracts Invoke-CapTest 'prebuilt fixture binder accepts exact artifacts and refuses missing or tampered bytes' { Invoke-CapPrebuiltFixtureContract } Invoke-CapTest 'journal phase timing records open copy total without changing budget or retry limits' { Invoke-CapJournalTimingContract } @@ -3784,6 +3984,6 @@ try { } [ordered]@{ suite = $suiteName; executed = $results.Count; passed = $passed; failed = $failed; artifacts = $scratch } | ConvertTo-Json -Compress -$expectedCount = if ($JournalContractOnly) { 10 } elseif ($LaunchContractOnly) { 2 } elseif ($RequireRealSeed) { 88 } else { 85 } +$expectedCount = if ($JournalContractOnly) { 10 } elseif ($LaunchContractOnly) { 2 } elseif ($RequireRealSeed) { 95 } else { 92 } if ($results.Count -ne $expectedCount -or $failed -ne 0 -or $passed -ne $results.Count) { exit 1 } exit 0 diff --git a/Tools/windows/Tests/ValidationRunner.Tests.ps1 b/Tools/windows/Tests/ValidationRunner.Tests.ps1 index 616a42c8..1375d9af 100644 --- a/Tools/windows/Tests/ValidationRunner.Tests.ps1 +++ b/Tools/windows/Tests/ValidationRunner.Tests.ps1 @@ -4729,7 +4729,7 @@ if ($LASTEXITCODE -ne 0) { throw "RED: offline real-launch capture child failed" $uiCaptureSummaries = @($uiCaptureOutput | Where-Object { $_ -is [string] -and $_.StartsWith('{"suite":"UiCaptureDriver",') }) if ($uiCaptureSummaries.Count -ne 1) { throw "RED: offline real-launch capture structured count missing or duplicated" } $uiCaptureSummary = ConvertFrom-Json $uiCaptureSummaries[0] -if ($uiCaptureSummary.executed -isnot [long] -or $uiCaptureSummary.executed -ne 85 -or +if ($uiCaptureSummary.executed -isnot [long] -or $uiCaptureSummary.executed -ne 92 -or $uiCaptureSummary.passed -isnot [long] -or $uiCaptureSummary.passed -ne $uiCaptureSummary.executed -or $uiCaptureSummary.failed -isnot [long] -or $uiCaptureSummary.failed -ne 0) { throw "RED: offline real-launch capture child has no positive executed/passed contract count" diff --git a/Tools/windows/UiCaptureDriver.psm1 b/Tools/windows/UiCaptureDriver.psm1 index b9b8b1f7..7f7272c8 100644 --- a/Tools/windows/UiCaptureDriver.psm1 +++ b/Tools/windows/UiCaptureDriver.psm1 @@ -389,22 +389,25 @@ try { foreach ($property in $spec.environment.PSObject.Properties) { $info.Environment[$property.Name] = [string]$property.Value } $process = [Diagnostics.Process]::Start($info) if ($null -eq $process) { throw 'Contained normal Process.Start returned null.' } - $snapshot = [CapNative]::Snapshot($process.Handle, $process.Id) + $access = Read-CapProcessAccess -Process $process -Role 'contained-target' -CallSite 'JobLauncher.InitialSnapshot' + $snapshot = [CapNative]::Snapshot($access.Handle, $access.Pid) if (-not $snapshot.IsExited -and $snapshot.ImagePath -ine $spec.filePath) { throw 'Contained target image differs.' } $outPump = $process.StandardOutput.BaseStream.CopyToAsync($stdout) $errPump = $process.StandardError.BaseStream.CopyToAsync($stderr) $process.StandardInput.Close() $null = Write-CapImmutableJson -Root $spec.root -Path $spec.targetReceiptPath -Value ([pscustomobject]@{ - jobId = $spec.jobId; specSha256 = $specHash; pid = [long]$process.Id + jobId = $spec.jobId; specSha256 = $specHash; pid = [long]$access.Pid creationFileTimeUtc = [long]$snapshot.CreationFileTimeUtc expectedImagePath = $spec.filePath; arguments = @($spec.arguments) attestation = 'NORMAL_START_RETAINED_HANDLE_NOT_PREEXECUTION_IDENTITY' }) while (-not $process.WaitForExit(100)) {} - $exit = [CapNative]::Snapshot($process.Handle, $process.Id) + $access = Read-CapProcessAccess -Process $process -Role 'contained-target' ` + -CallSite 'JobLauncher.ExitSnapshot' -ExpectedPid $snapshot.ProcessId + $exit = [CapNative]::Snapshot($access.Handle, $access.Pid) if (-not $exit.IsExited) { throw 'Contained target exit lacks a signalled original handle.' } $null = Write-CapImmutableJson -Root $spec.root -Path $spec.exitReceiptPath -Value ([pscustomobject]@{ - jobId = $spec.jobId; specSha256 = $specHash; pid = [long]$process.Id + jobId = $spec.jobId; specSha256 = $specHash; pid = [long]$access.Pid creationFileTimeUtc = [long]$snapshot.CreationFileTimeUtc; exitCode = [long]$process.ExitCode; kernelExited = $true }) # The job owner may terminate this host while daemon descendants still hold @@ -611,10 +614,12 @@ function Get-CapJobMembers { } $retained = $false try { - if (-not [CapProcessJob]::Contains($Run.Job, $process.Handle)) { + $access = Read-CapProcessAccess -Process $process -Role 'job-member' ` + -CallSite 'Get-CapJobMembers.Snapshot' -ExpectedPid $memberPid + if (-not [CapProcessJob]::Contains($Run.Job, $access.Handle)) { throw 'Census PID does not identify a held job-member generation.' } - $snapshot = [CapNative]::Snapshot($process.Handle, $memberPid) + $snapshot = [CapNative]::Snapshot($access.Handle, $access.Pid) if ($snapshot.IsExited) { continue } $existing = @($Run.Members | Where-Object { $_.Pid -eq $memberPid -and $_.CreationFileTimeUtc -eq $snapshot.CreationFileTimeUtc }) @@ -1595,6 +1600,66 @@ function ConvertTo-CapImagePath { return [IO.Path]::GetFullPath($value) } +function Read-CapProcessAccess { + [CmdletBinding()] + param( + [Parameter(Mandatory)][Diagnostics.Process]$Process, + [Parameter(Mandatory)][string]$Role, + [Parameter(Mandatory)][string]$CallSite, + [Nullable[int]]$ExpectedPid = $null + ) + $handle = $null + $processId = $null + $handleState = $null + $idState = $null + $failures = [Collections.Generic.List[Exception]]::new() + # Explicit accessors preserve failures that PowerShell's adapted properties + # otherwise turn into null before the native method binder sees them. + try { $handle = $Process.get_Handle() } + catch [Management.Automation.MethodInvocationException], [InvalidOperationException], [ComponentModel.Win32Exception] { + $handleState = 'GetterException' + $failures.Add($_.Exception) + } + try { $processId = $Process.get_Id() } + catch [Management.Automation.MethodInvocationException], [InvalidOperationException], [ComponentModel.Win32Exception] { + $idState = 'GetterException' + $failures.Add($_.Exception) + } + if ($null -eq $handleState) { + $handleState = if ($null -eq $handle) { 'Null' } + elseif ($handle -isnot [IntPtr] -or $handle -eq [IntPtr]::Zero -or $handle -eq [IntPtr]::new(-1)) { 'Invalid' } + else { 'Available' } + } + if ($null -eq $idState) { + $idState = if ($null -eq $processId) { 'Null' } + elseif ($processId -isnot [int] -or $processId -le 0) { 'Invalid' } + else { 'Available' } + } + if ($handleState -cne 'Available' -or $idState -cne 'Available') { + $inner = if ($failures.Count -eq 1) { $failures[0] } + elseif ($failures.Count -gt 1) { [AggregateException]::new('Process accessor failures.', $failures.ToArray()) } + else { $null } + $failure = [InvalidOperationException]::new( + "ProcessIdentityUnavailable: $Role at $CallSite; Handle=$handleState; Id=$idState; kernel exit and EOF are UNPROVEN.", $inner) + $failure.Data['CapCaptureCode'] = 'ProcessIdentityUnavailable' + $failure.Data['Role'] = $Role + $failure.Data['CallSite'] = $CallSite + $failure.Data['HandleState'] = $handleState + $failure.Data['IdState'] = $idState + $failure.Data['AcquiredPid'] = if ($idState -ceq 'Available') { $processId } else { $null } + $failure.Data['ExpectedPid'] = $ExpectedPid + throw $failure + } + if ($null -ne $ExpectedPid -and $processId -ne $ExpectedPid) { + $failure = [InvalidOperationException]::new("Tracked PID changed for $Role at $CallSite; acquired=$processId expected=$ExpectedPid.") + $failure.Data['CapCaptureCode'] = 'ProcessIdentityMismatch' + $failure.Data['AcquiredPid'] = $processId + $failure.Data['ExpectedPid'] = $ExpectedPid + throw $failure + } + return [pscustomobject]@{ Handle = $handle; Pid = $processId } +} + function Get-CapProcessIdentity { [CmdletBinding()] param( @@ -1607,8 +1672,9 @@ function Get-CapProcessIdentity { Write-CapTrace -Command $MyInvocation.MyCommand.Name -Parameters $PSBoundParameters - $snapshot = [CapNative]::Snapshot($Process.Handle, $Process.Id) - if ($snapshot.ProcessId -ne $Process.Id) { throw "Kernel PID changed while tracking $Role." } + $access = Read-CapProcessAccess -Process $Process -Role $Role -CallSite 'Get-CapProcessIdentity.InitialSnapshot' + $snapshot = [CapNative]::Snapshot($access.Handle, $access.Pid) + if ($snapshot.ProcessId -ne $access.Pid) { throw "Kernel PID changed while tracking $Role." } $expected = ConvertTo-CapImagePath -Path $ExpectedImagePath if (-not $snapshot.IsExited -and (ConvertTo-CapImagePath -Path $snapshot.ImagePath) -ine $expected) { throw "Live $Role executable mismatch: $($snapshot.ImagePath) != $expected." @@ -1621,12 +1687,14 @@ function Get-CapProcessIdentity { $concludedGoneByKernelExit = [bool]$snapshot.IsExited if (-not $snapshot.IsExited) { for ($attempt = 0; $attempt -lt 20; $attempt++) { - $state = Read-CapProcessMetadataState -Pid $Process.Id + $state = Read-CapProcessMetadataState -Pid $access.Pid # The retained kernel handle governs an Absent/Incomplete reading. EOF is # proven later by the launch caller, so the loop passes BothStreamsEofReached # = $false and treats either exited disposition as "break and defer the EOF # gate to the caller". - $snapshot = [CapNative]::Snapshot($Process.Handle, $Process.Id) + $access = Read-CapProcessAccess -Process $Process -Role $Role ` + -CallSite 'Get-CapProcessIdentity.MetadataSnapshot' -ExpectedPid $snapshot.ProcessId + $snapshot = [CapNative]::Snapshot($access.Handle, $access.Pid) $disposition = Resolve-CapAttestationDisposition -State $state.State ` -KernelExited ([bool]$snapshot.IsExited) -BothStreamsEofReached $false $terminalState = if ($snapshot.IsExited) { "KernelExitedWith$($state.State)" } else { $state.State } @@ -1637,14 +1705,14 @@ function Get-CapProcessIdentity { if ($disposition -eq 'RefuseLiveIncomplete') { # A torn-down row on a still-live owned child is refused outright, # exactly as main did; it is never retried into a passing outcome. - throw "Live $Role PID $($Process.Id) has incomplete current CIM metadata; refusing ownership inference." + throw "Live $Role PID $($access.Pid) has incomplete current CIM metadata; refusing ownership inference." } # RetryAbsent: not yet listed on a live child -> bounded startup re-poll. Start-Sleep -Milliseconds 50 } if ($null -eq $metadata -and -not $concludedGoneByKernelExit -and -not $snapshot.IsExited -and ($ExpectedParentPid -lt 0 -or $ExpectedSessionId -lt 0)) { - throw "Live $Role PID $($Process.Id) has no current CIM metadata." + throw "Live $Role PID $($access.Pid) has no current CIM metadata." } } $expectedMetadataMatches = if ($metadata) { @@ -1819,8 +1887,9 @@ function Assert-CapProcessIdentity { Write-CapTrace -Command $MyInvocation.MyCommand.Name -Parameters $PSBoundParameters - if ($Identity.Pid -ne $Identity.Process.Id) { throw "Tracked PID changed for $($Identity.Role)." } - $snapshot = [CapNative]::Snapshot($Identity.Process.Handle, $Identity.Pid) + $access = Read-CapProcessAccess -Process $Identity.Process -Role $Identity.Role ` + -CallSite 'Assert-CapProcessIdentity.Snapshot' -ExpectedPid $Identity.Pid + $snapshot = [CapNative]::Snapshot($access.Handle, $access.Pid) if ($snapshot.IsExited) { return [pscustomobject]@{ State = 'Exited'; Snapshot = $snapshot; Metadata = $null } } if ($snapshot.ProcessId -ne $Identity.Pid -or $snapshot.CreationFileTimeUtc -ne [long]$Identity.CreationFileTimeUtc -or @@ -1834,7 +1903,9 @@ function Assert-CapProcessIdentity { # never treated as proof of death on its own. If the owned generation has # exited, report it exited; if it is still live, an incomplete/absent row # is a refusal, not a silent pass. - if ([CapNative]::IsExited($Identity.Process.Handle, $Identity.Pid)) { return [pscustomobject]@{ State = 'Exited'; Snapshot = $snapshot; Metadata = $null } } + $access = Read-CapProcessAccess -Process $Identity.Process -Role $Identity.Role ` + -CallSite 'Assert-CapProcessIdentity.MetadataExit' -ExpectedPid $Identity.Pid + if ([CapNative]::IsExited($access.Handle, $access.Pid)) { return [pscustomobject]@{ State = 'Exited'; Snapshot = $snapshot; Metadata = $null } } throw "Live PID $($Identity.Pid) has no complete current CIM row; refusing." } $metadata = $state.Metadata @@ -1856,8 +1927,10 @@ function Stop-CapManagedProcess { if ($verified.State -eq 'Exited') { return [pscustomobject]@{ Pid = $Identity.Pid; Role = $Identity.Role; Result = 'SKIP_GENUINELY_EXITED_KERNEL' } } - $terminated = [CapNative]::Terminate($Identity.Process.Handle, $Identity.Pid, $ExitCode, $TimeoutMilliseconds) - if (-not [CapNative]::IsExited($Identity.Process.Handle, $Identity.Pid)) { throw "Owned process PID $($Identity.Pid) remains live after guarded termination." } + $access = Read-CapProcessAccess -Process $Identity.Process -Role $Identity.Role ` + -CallSite 'Stop-CapManagedProcess.Terminate' -ExpectedPid $Identity.Pid + $terminated = [CapNative]::Terminate($access.Handle, $access.Pid, $ExitCode, $TimeoutMilliseconds) + if (-not [CapNative]::IsExited($access.Handle, $access.Pid)) { throw "Owned process PID $($Identity.Pid) remains live after guarded termination." } return [pscustomobject]@{ Pid = $Identity.Pid; Role = $Identity.Role Result = if ($terminated) { 'TERMINATED_GUARDED_HANDLE' } else { 'EXITED_DURING_GUARDED_STOP' } } } @@ -1873,7 +1946,8 @@ function Wait-CapManagedProcess { $process = $Run.Identity.Process - if ($process.Id -ne $Run.Identity.Pid) { throw "Held process PID changed for $($Run.Identity.Role)." } + $null = Read-CapProcessAccess -Process $process -Role $Run.Identity.Role ` + -CallSite 'Wait-CapManagedProcess.Wait' -ExpectedPid $Run.Identity.Pid while ([DateTime]::UtcNow -lt $DeadlineUtc) { $remaining = [int][Math]::Max(1, [Math]::Min( $PollMilliseconds, @@ -1897,36 +1971,41 @@ function Invoke-CapLaunchRollback { $process = $LaunchRecord.Process - $snapshot = [CapNative]::Snapshot($process.Handle, $process.Id) + $access = Read-CapProcessAccess -Process $process -Role $LaunchRecord.Role ` + -CallSite 'Invoke-CapLaunchRollback.InitialSnapshot' -ExpectedPid $LaunchRecord.AcquiredPid + $LaunchRecord.AcquiredPid = $access.Pid + $snapshot = [CapNative]::Snapshot($access.Handle, $access.Pid) $result = $null if ($snapshot.IsExited) { $result = 'SKIP_GENUINELY_EXITED_KERNEL' } else { $metadata = $null for ($attempt = 0; $attempt -lt 20; $attempt++) { - $metadata = Get-CapCurrentMetadata -Pid $process.Id + $metadata = Get-CapCurrentMetadata -Pid $access.Pid if ($metadata) { break } - $snapshot = [CapNative]::Snapshot($process.Handle, $process.Id) + $access = Read-CapProcessAccess -Process $process -Role $LaunchRecord.Role ` + -CallSite 'Invoke-CapLaunchRollback.MetadataSnapshot' -ExpectedPid $snapshot.ProcessId + $snapshot = [CapNative]::Snapshot($access.Handle, $access.Pid) if ($snapshot.IsExited) { break } Start-Sleep -Milliseconds 50 } if ($snapshot.IsExited) { $result = 'SKIP_GENUINELY_EXITED_KERNEL' } elseif ($null -eq $metadata) { - throw "Rollback refused for live PID $($process.Id): current process metadata is unavailable." + throw "Rollback refused for live PID $($access.Pid): current process metadata is unavailable." } else { $expectedPath = ConvertTo-CapImagePath -Path $LaunchRecord.ExpectedImagePath - if ($metadata.Pid -ne $process.Id -or + if ($metadata.Pid -ne $access.Pid -or [Math]::Abs([double]([long]$metadata.CreationFileTimeUtc - [long]$snapshot.CreationFileTimeUtc)) -gt [TimeSpan]::FromMilliseconds(100).Ticks -or $metadata.ImagePath -ine $expectedPath -or $metadata.ParentPid -ne $LaunchRecord.ExpectedParentPid -or $metadata.SessionId -ne $LaunchRecord.ExpectedSessionId) { - throw "Rollback refused for live PID $($process.Id): current kernel/CIM identity differs from the held launch record." + throw "Rollback refused for live PID $($access.Pid): current kernel/CIM identity differs from the held launch record." } $identity = [pscustomobject]@{ Process = $process - Pid = $process.Id + Pid = $access.Pid CreationFileTimeUtc = $snapshot.CreationFileTimeUtc ImagePath = $expectedPath ExpectedImagePath = $expectedPath @@ -1943,13 +2022,15 @@ function Invoke-CapLaunchRollback { } if ($null -eq $LaunchRecord.StdoutTask) { $LaunchRecord.StdoutTask = $process.StandardOutput.ReadToEndAsync() } if ($null -eq $LaunchRecord.StderrTask) { $LaunchRecord.StderrTask = $process.StandardError.ReadToEndAsync() } - if (-not $LaunchRecord.StdoutTask.Wait(2000)) { throw "Rollback stdout reader did not reach bounded EOF for PID $($process.Id)." } - if (-not $LaunchRecord.StderrTask.Wait(2000)) { throw "Rollback stderr reader did not reach bounded EOF for PID $($process.Id)." } + if (-not $LaunchRecord.StdoutTask.Wait(2000)) { throw "Rollback stdout reader did not reach bounded EOF for PID $($access.Pid)." } + if (-not $LaunchRecord.StderrTask.Wait(2000)) { throw "Rollback stderr reader did not reach bounded EOF for PID $($access.Pid)." } $stdout = [string]$LaunchRecord.StdoutTask.Result $stderr = [string]$LaunchRecord.StderrTask.Result - if (-not [CapNative]::IsExited($process.Handle, $process.Id)) { throw "Rollback left live PID $($process.Id)." } + $access = Read-CapProcessAccess -Process $process -Role $LaunchRecord.Role ` + -CallSite 'Invoke-CapLaunchRollback.FinalExit' -ExpectedPid $snapshot.ProcessId + if (-not [CapNative]::IsExited($access.Handle, $access.Pid)) { throw "Rollback left live PID $($access.Pid)." } return [pscustomobject]@{ - Pid = $process.Id + Pid = $access.Pid Role = $LaunchRecord.Role ExpectedPath = $LaunchRecord.ExpectedImagePath CreationFileTimeUtc = $snapshot.CreationFileTimeUtc @@ -1962,6 +2043,46 @@ function Invoke-CapLaunchRollback { } } +function Complete-CapFailedLaunch { + [CmdletBinding()] + param([Parameter(Mandatory)]$LaunchRecord, [Parameter(Mandatory)][Exception]$StartFailure) + $LaunchRecord.StartFailure = $StartFailure.Message + $LaunchRecord.StartFailureException = $StartFailure + if ($null -eq $LaunchRecord.AcquiredPid -and $StartFailure.Data['AcquiredPid'] -is [int]) { + $LaunchRecord.AcquiredPid = $StartFailure.Data['AcquiredPid'] + } + $pidText = if ($null -ne $LaunchRecord.AcquiredPid) { [string]$LaunchRecord.AcquiredPid } else { 'UNAVAILABLE' } + try { + $rollback = Invoke-CapLaunchRollback -LaunchRecord $LaunchRecord + $script:CapLastRollbackReceipt = $rollback + $null = $script:CapPendingLaunches.Remove($LaunchRecord) + $LaunchRecord.Process.Dispose() + } catch { + $LaunchRecord.RollbackFailure = $_.Exception.Message + $LaunchRecord.RollbackFailureException = $_.Exception + $script:CapLastRollbackReceipt = [pscustomobject]@{ + Pid = $LaunchRecord.AcquiredPid + Role = $LaunchRecord.Role + ExpectedPath = $LaunchRecord.ExpectedImagePath + CreationFileTimeUtc = if ($LaunchRecord.Snapshot) { $LaunchRecord.Snapshot.CreationFileTimeUtc } else { $null } + KernelExited = if ($LaunchRecord.Snapshot) { [bool]$LaunchRecord.Snapshot.IsExited } else { $null } + RollbackFailure = $LaunchRecord.RollbackFailure + RefusedUnsafeTermination = $true + } + $failure = [InvalidOperationException]::new( + "Process.Start identity attestation failed for $($LaunchRecord.Role) PID ${pidText}: $($StartFailure.Message); guarded rollback also failed: $($LaunchRecord.RollbackFailure).", + [AggregateException]::new('Primary attestation and guarded rollback failures.', + [Exception[]]@($StartFailure, $LaunchRecord.RollbackFailureException))) + foreach ($key in $StartFailure.Data.Keys) { $failure.Data[$key] = $StartFailure.Data[$key] } + throw $failure + } + $failure = [InvalidOperationException]::new( + "Process.Start identity attestation failed for $($LaunchRecord.Role) PID ${pidText}: $($StartFailure.Message); guarded rollback=$($rollback.Result), stdoutEOF=$($rollback.StdoutEof), stderrEOF=$($rollback.StderrEof).", + $StartFailure) + foreach ($key in $StartFailure.Data.Keys) { $failure.Data[$key] = $StartFailure.Data[$key] } + throw $failure +} + function Start-CapManagedProcessCore { [CmdletBinding()] param( @@ -2007,21 +2128,26 @@ function Start-CapManagedProcessCore { Role = $Role StartInfo = $startInfo Snapshot = $null + AcquiredPid = $null StdoutTask = $null StderrTask = $null Identity = $null StartFailure = $null RollbackFailure = $null + StartFailureException = $null + RollbackFailureException = $null } $script:CapPendingLaunches.Add($launchRecord) try { - $launchRecord.Snapshot = [CapNative]::Snapshot($process.Handle, $process.Id) + $access = Read-CapProcessAccess -Process $process -Role $Role -CallSite 'Start-CapManagedProcessCore.InitialSnapshot' + $launchRecord.AcquiredPid = $access.Pid + $launchRecord.Snapshot = [CapNative]::Snapshot($access.Handle, $access.Pid) $launchRecord.StdoutTask = $process.StandardOutput.ReadToEndAsync() $launchRecord.StderrTask = $process.StandardError.ReadToEndAsync() Write-CapTrace -Command 'Process.Start' -Parameters @{ FilePath = $fullPath; ArgumentList = $ArgumentList; Role = $Role - Pid = $process.Id; CreationFileTimeUtc = $launchRecord.Snapshot.CreationFileTimeUtc - HeldKernelHandle = $process.Handle.ToInt64(); ExpectedParentPid = $PID + Pid = $access.Pid; CreationFileTimeUtc = $launchRecord.Snapshot.CreationFileTimeUtc + HeldKernelHandle = $access.Handle.ToInt64(); ExpectedParentPid = $PID ExpectedSessionId = $parentMetadata.SessionId } if (-not $launchRecord.Snapshot.IsExited -and @@ -2034,13 +2160,15 @@ function Start-CapManagedProcessCore { Get-CapProcessIdentity -Process $process -ExpectedImagePath $fullPath -Role $Role ` -ExpectedParentPid $PID -ExpectedSessionId $parentMetadata.SessionId } - if ($identity.Pid -ne $process.Id -or $identity.CreationFileTimeUtc -ne $launchRecord.Snapshot.CreationFileTimeUtc) { + if ($identity.Pid -ne $launchRecord.AcquiredPid -or $identity.CreationFileTimeUtc -ne $launchRecord.Snapshot.CreationFileTimeUtc) { throw "Identity attestor returned a different kernel generation for $Role." } - $exitedDuringAttestation = [CapNative]::IsExited($process.Handle, $process.Id) + $access = Read-CapProcessAccess -Process $process -Role $Role ` + -CallSite 'Start-CapManagedProcessCore.AttestationExit' -ExpectedPid $launchRecord.AcquiredPid + $exitedDuringAttestation = [CapNative]::IsExited($access.Handle, $access.Pid) if (-not $exitedDuringAttestation) { if (-not $identity.InitialMetadataObserved -or -not $identity.InitialMetadataMatchesExpected) { - throw "Live $Role PID $($process.Id) lacks a complete expected-parent/session attestation." + throw "Live $Role PID $($launchRecord.AcquiredPid) lacks a complete expected-parent/session attestation." } # Consume the verification result: the child can exit during this final # assertion, and that exit must also be routed through the shared EOF gate @@ -2058,34 +2186,14 @@ function Start-CapManagedProcessCore { $bothEof = $launchRecord.StdoutTask.Wait(2000) -and $launchRecord.StderrTask.Wait(2000) $final = Resolve-CapAttestationDisposition -State 'Absent' -KernelExited $true -BothStreamsEofReached $bothEof if ($final -cne 'GoneByKernelExit') { - throw "Exited $Role PID $($process.Id) did not reach bounded stdout/stderr EOF; cannot attest it genuinely gone." + throw "Exited $Role PID $($launchRecord.AcquiredPid) did not reach bounded stdout/stderr EOF; cannot attest it genuinely gone." } } $launchRecord.Identity = $identity if ($CloseStdin) { $process.StandardInput.Close() } $null = $script:CapPendingLaunches.Remove($launchRecord) } catch { - $startFailure = $_.Exception - $launchRecord.StartFailure = $startFailure.Message - try { - $rollback = Invoke-CapLaunchRollback -LaunchRecord $launchRecord - $script:CapLastRollbackReceipt = $rollback - $null = $script:CapPendingLaunches.Remove($launchRecord) - $process.Dispose() - } catch { - $launchRecord.RollbackFailure = $_.Exception.Message - $script:CapLastRollbackReceipt = [pscustomobject]@{ - Pid = $process.Id - Role = $Role - ExpectedPath = $fullPath - CreationFileTimeUtc = if ($launchRecord.Snapshot) { $launchRecord.Snapshot.CreationFileTimeUtc } else { $null } - KernelExited = if ($launchRecord.Snapshot) { [bool]$launchRecord.Snapshot.IsExited } else { $null } - RollbackFailure = $launchRecord.RollbackFailure - RefusedUnsafeTermination = $true - } - throw "Process.Start identity attestation failed for $Role PID $($process.Id): $($startFailure.Message); guarded rollback also failed: $($launchRecord.RollbackFailure)." - } - throw "Process.Start identity attestation failed for $Role PID $($process.Id): $($startFailure.Message); guarded rollback=$($rollback.Result), stdoutEOF=$($rollback.StdoutEof), stderrEOF=$($rollback.StderrEof)." + Complete-CapFailedLaunch -LaunchRecord $launchRecord -StartFailure $_.Exception } return [pscustomobject]@{ Identity = $identity @@ -2165,10 +2273,18 @@ function Get-CapPendingLaunches { $record = $_ $kernelExited = $null $kernelProbeFailure = $null - try { $kernelExited = [CapNative]::IsExited($record.Process.Handle, $record.Process.Id) } - catch [InvalidOperationException], [ComponentModel.Win32Exception] { $kernelProbeFailure = $_.Exception.ToString() } + $kernelProbeFailureDetails = $null + try { + $access = Read-CapProcessAccess -Process $record.Process -Role $record.Role ` + -CallSite 'Get-CapPendingLaunches.KernelProbe' -ExpectedPid $record.AcquiredPid + $kernelExited = [CapNative]::IsExited($access.Handle, $access.Pid) + } + catch [InvalidOperationException], [ComponentModel.Win32Exception] { + $kernelProbeFailure = $_.Exception.ToString() + $kernelProbeFailureDetails = $_.Exception.Data + } [pscustomobject]@{ - Pid = $record.Process.Id + Pid = $record.AcquiredPid Role = $record.Role ExpectedPath = $record.ExpectedImagePath CreationFileTimeUtc = if ($record.Snapshot) { $record.Snapshot.CreationFileTimeUtc } else { $null } @@ -2176,8 +2292,11 @@ function Get-CapPendingLaunches { ExpectedSessionId = $record.ExpectedSessionId StartFailure = $record.StartFailure RollbackFailure = $record.RollbackFailure + StartFailureDetails = if ($record.StartFailureException) { $record.StartFailureException.Data } else { $null } + RollbackFailureDetails = if ($record.RollbackFailureException) { $record.RollbackFailureException.Data } else { $null } KernelExited = $kernelExited KernelProbeFailure = $kernelProbeFailure + KernelProbeFailureDetails = $kernelProbeFailureDetails } }) } @@ -2199,6 +2318,7 @@ function Resolve-CapPendingLaunches { $results = [Collections.Generic.List[object]]::new() $failures = [Collections.Generic.List[string]]::new() + $exceptions = [Collections.Generic.List[Exception]]::new() foreach ($record in $script:CapPendingLaunches.ToArray()) { try { $receipt = Invoke-CapLaunchRollback -LaunchRecord $record @@ -2206,10 +2326,15 @@ function Resolve-CapPendingLaunches { $null = $script:CapPendingLaunches.Remove($record) $record.Process.Dispose() } catch { - $failures.Add("PID $($record.Process.Id) role=$($record.Role): $($_.Exception.Message)") + $pidText = if ($null -ne $record.AcquiredPid) { [string]$record.AcquiredPid } else { 'UNAVAILABLE' } + $failures.Add("PID $pidText role=$($record.Role): $($_.Exception.Message)") + $exceptions.Add($_.Exception) } } - if ($failures.Count) { throw "Pending launch rollback refused or failed: $($failures -join '; ')" } + if ($failures.Count) { + throw [InvalidOperationException]::new("Pending launch rollback refused or failed: $($failures -join '; ')", + [AggregateException]::new('Pending launch rollback failures.', $exceptions.ToArray())) + } return @($results) } @@ -2246,7 +2371,9 @@ function Get-CapOwnedDescendants { $verified = Assert-CapProcessIdentity -Identity $identity if ($verified.State -eq 'Exited') { $process.Dispose(); continue } $meta = $verified.Metadata - $parentSnapshot = [CapNative]::Snapshot($parent.Process.Handle, $parent.Pid) + $parentAccess = Read-CapProcessAccess -Process $parent.Process -Role $parent.Role ` + -CallSite 'Get-CapOwnedDescendants.ParentSnapshot' -ExpectedPid $parent.Pid + $parentSnapshot = [CapNative]::Snapshot($parentAccess.Handle, $parentAccess.Pid) $parentExitFileTimeUtc = [long]$parentSnapshot.ExitFileTimeUtc $outsideParentLifetime = ([long]$meta.CreationFileTimeUtc -lt [long]$parent.CreationFileTimeUtc) -or @@ -3689,6 +3816,7 @@ Export-ModuleMember -Function @( 'Find-CapTopLevelWindow', 'Capture-CapWindow', 'Get-CapProcessIdentity', + 'Read-CapProcessAccess', 'Read-CapProcessMetadataState', 'Resolve-CapAttestationDisposition', 'Get-CapCurrentMetadata', diff --git a/windows-tests/ui-scenarios/README.md b/windows-tests/ui-scenarios/README.md index 1a54ac6a..58f00c99 100644 --- a/windows-tests/ui-scenarios/README.md +++ b/windows-tests/ui-scenarios/README.md @@ -386,13 +386,13 @@ Test definitions retain the original 59 cases (the native export contract is updated to 26), adding three contained harmless normal/nonzero/timeout cases, one STD source-ordering check, and N1's full numbered-final-proof synthetic output-descendant accept/refusal fixture, plus one negative umbrella safety-net -release assertion, lease-path and receipt-IO groups, prebuilt binding, journal-phase timing, first-Open, buffered-observation and selector-refusal contracts: 85 expected cases. N1 is not real +release assertion, lease-path and receipt-IO groups, prebuilt binding, journal-phase timing, first-Open, buffered-observation, selector-refusal and identity-acquisition contracts: 92 expected cases. N1 is not real seed evidence. `-RequireRealSeed` additionally defines three actual-command cases: two real zmx phases before a normal, nonzero, or timed-out harmless -target, for 88 expected cases. It requires explicit `-RealSeedZmxPath` and +target, for 95 expected cases. It requires explicit `-RealSeedZmxPath` and `-RealSeedZmxSha256` inputs admitted against the pinned provider; there is no PATH or stale-candidate fallback. Without those cases, the repaired report -gate refuses live admission even if the 85 offline cases pass. +gate refuses live admission even if the 92 offline cases pass. None of these new native cases ran during M1. Parse/compile-only evidence does not establish job nesting, gate timing, output transport, native STD @@ -475,7 +475,7 @@ test override. Retry trace observations assert actual sharing/parse retries. release case for discriminating RED/GREEN evidence. These five contracts are registered as one full-suite case within the current -85/88 prospective counts above. Focused reports are named +92/95 prospective counts above. Focused reports are named `UiCaptureReceiptContracts` and cannot stand in for a complete capture preflight. A file-IO GREEN is not native lifecycle or job-zero GREEN. The strict outer job-zero gate remains unchanged; there is no vctip exemption. @@ -631,7 +631,8 @@ The two prior #845 setup groups gave the historical 69 offline / 72 real-seed inventory. The two #851 first-Open groups brought that inventory to 71 offline / 74 real-seed cases. The #852 buffered-observation group brought that inventory to 72 offline / 75 real-seed cases. The thirteen #848 selector-refusal groups -bring current full-suite expectations to 85 offline / 88 real-seed cases. Historical run counts are not +bring that inventory to 85 offline / 88 real-seed cases. The seven identity-acquisition +groups bring current full-suite expectations to 92 offline / 95 real-seed cases. Historical run counts are not relabelled by these additions. A focused setup-contract pass is not a complete capture, native job-zero or parity pass. @@ -723,10 +724,56 @@ not an instantiated WPF rectangle or live UIA evidence. Structural classification uses the four already-read primitive coordinates, without adding a WindowsBase dependency or changing assembly loading. -All thirteen cases are also registered in the normal suite: exactly 85 ordinary -or 88 real-seed cases, preserving all 75 real-seed case names from the merged +All thirteen cases are also registered in the normal suite: currently 92 ordinary +or 95 real-seed cases, preserving all 75 real-seed case names from the merged journal base. The four timing subcases and journal success path are unchanged. This diagnostic evidence is not live UIA, graph-delivery, rendering, input, B-scene or parity evidence; the live refusal's root cause remains unclassified until separately admitted retained runtime evidence is available. + +### Typed Process identity acquisition + +`Read-CapProcessAccess` explicitly invokes `Process.get_Handle()` and +`Process.get_Id()` once each per acquisition boundary. Unlike PowerShell's +adapted property reads, these invocations retain accessor exceptions instead +of passing a null value to the `Snapshot` argument binder. The shared helper is +exported for the bound embedded launcher and supervisor; it does not open or +adopt a different process. + +Unavailable, null or invalid access is an `InvalidOperationException` with +`Data.CapCaptureCode=ProcessIdentityUnavailable`, role, callsite, independent +Handle/Id states, and the original accessor exception(s). A valid acquired +PID and a caller's expected PID are separate nullable evidence fields; an +expected PID is never substituted for an unavailable acquired PID. Only an +actual nonzero/non-minus-one `IntPtr` and positive `Int32` reach `Snapshot`. +An acquired/expected PID mismatch refuses ownership. The native snapshot's +PID, creation-time, image and kernel checks are unchanged. + +Launch error formatting uses retained acquired PID evidence or the explicit +word `UNAVAILABLE`, not another read of a disposed object. The original typed +attestation error and any secondary rollback error remain observable. +Unavailable rollback retains its pending record, reports kernel exit as +unproven, and refuses unsafe termination. Pending probe failure details remain +typed, with `KernelExited=null`; neither this value, a missing CIM row nor a +getter exception proves exit or bounded stdout/stderr EOF. Existing strict +generation, current metadata, parent/session, deadline and EOF gates remain +in force. + +The seven registered focused cases exercise actual not-started/disposed +self-process wrappers, synthetic null/invalid/independent-failure accessor +states, valid self kernel/CIM identity, PID/native mismatches, disposed tracked +assert/stop/wait refusal, and the actual failed-launch handler/rollback/pending +formatting path without starting a child: + +```powershell +pwsh -NoProfile -File Tools\windows\Tests\UiCaptureDriver.Tests.ps1 ` + -IdentityContractOnly -ScratchRoot C:\owned\fresh-identity-contracts +``` + +The unchanged-source RED reaches the actual initial `Get-CapProcessIdentity` +Snapshot binder with a null Handle; it does not enter the native method body. +Controlled disposal demonstrates that reproduction mechanism, not the cause +of either historical #849 occurrence. These self-process and synthetic +contracts are not native child-lifecycle, full-suite, UIA, live B or parity +evidence.