--- provision-promote-VGCTKD7A.ps1 +++ provision-promote-W8TQ3MZC.ps1 @@ -91,6 +91,133 @@ $Record.job.assigned -eq $true -and $Record.job.membership_read -eq $true -and $Record.job.kill_on_close_read -eq $true -and $Record.job.limit_flags -eq 8192 -and $null -ne $Record.job.active_processes -and $Record.job.active_processes -eq 0 -and $null -eq $Record.error -and $null -ne $Record.ended_utc) 'NATIVE_OWNERSHIP_NOT_CONFIRMED_GONE' +} +function Read-NativeCensusProcess([uint32]$ProcessId) { + # Same limited-query / retained-handle identity seam as prep-host-readonly.ps1. + if (-not ('PromotionCensusNative' -as [type])) { + Add-Type -TypeDefinition @' +using System; +using System.Text; +using System.Runtime.InteropServices; +using System.ComponentModel; +public static class PromotionCensusNative { + [StructLayout(LayoutKind.Sequential)] struct FT { + public uint low, high; + public long Value { get { return ((long)high << 32) | low; } } + } + [DllImport("kernel32.dll", SetLastError=true)] static extern IntPtr OpenProcess(uint access, bool inherit, uint pid); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool GetProcessTimes(IntPtr h, out FT c, out FT e, out FT k, out FT u); + [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] static extern bool QueryFullProcessImageNameW(IntPtr h, uint flags, StringBuilder p, ref uint n); + [DllImport("kernel32.dll", SetLastError=true)] static extern uint WaitForSingleObject(IntPtr h, uint ms); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool CloseHandle(IntPtr h); + public static object Read(uint pid) { + IntPtr h = OpenProcess(0x00101000, false, pid); + if (h == IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error()); + try { + FT c, e, k, u; + if (!GetProcessTimes(h, out c, out e, out k, out u)) throw new Win32Exception(Marshal.GetLastWin32Error()); + uint w = WaitForSingleObject(h, 0); + if (w != 0 && w != 258) throw new Win32Exception(w == 0xffffffff ? Marshal.GetLastWin32Error() : 13); + string image = null; + if (w == 258) { + var b = new StringBuilder(32768); uint n = (uint)b.Capacity; + if (!QueryFullProcessImageNameW(h, 0, b, ref n)) throw new Win32Exception(Marshal.GetLastWin32Error()); + image = b.ToString(); + } + return new { pid=pid, created=DateTime.FromFileTimeUtc(c.Value).ToString("o"), exe=image, live=w == 258 }; + } finally { if (!CloseHandle(h)) throw new Win32Exception(Marshal.GetLastWin32Error()); } + } +} +'@ + } + return [PromotionCensusNative]::Read($ProcessId) +} +function Get-RigCensusDecision($Process, [DateTime]$RigCreatedUtc, [string]$RigExe) { + $answer = @{ allowed = $false; disposition = 'SPT_PROCESS_IDENTITY_UNRESOLVED'; + pid = $Process.ProcessId; created_utc = $null; original_gone = $false; + observations = [Collections.Generic.List[object]]::new() } + $current = $Process + # One immediate CIM re-read; a replacement is classified independently, never ignored. + for ($attempt = 0; $attempt -lt 2; $attempt++) { + if ($current.CreationDate -isnot [DateTime]) { + $answer.disposition = 'SPT_PROCESS_BIRTH_UNREADABLE'; return $answer + } + $birth = $current.CreationDate.ToUniversalTime() + if ($attempt -eq 0) { $answer.created_utc = $birth.ToString('o') } + $answer.observations.Add(@{ source = 'CIM'; pid = $current.ProcessId; + created_utc = $birth.ToString('o'); image = $current.ExecutablePath }) + # CIM has microsecond resolution. Its uncertainty can only REDUCE the exemption. + if ($birth.AddTicks(10) -lt $RigCreatedUtc) { + $answer.allowed = $true; $answer.disposition = 'PREDATES_RIG'; return $answer + } + if (-not [string]::IsNullOrWhiteSpace($current.ExecutablePath)) { + $answer.allowed = -not (Same-Path $current.ExecutablePath $RigExe) + $answer.disposition = if ($answer.allowed) { 'OTHER_IMAGE' } else { 'RIG_DAEMON_ALREADY_PRESENT' } + return $answer + } + $native = $null + try { + $native = Read-NativeCensusProcess ([uint32]$current.ProcessId) + $nativeBirth = [DateTimeOffset]::Parse($native.created).UtcDateTime + Assert ($native.pid -eq $current.ProcessId) 'NATIVE_CENSUS_PID_MISMATCH' + } catch { + $nativeFailure = $_.Exception.GetBaseException() + $answer.observations.Add(@{ source = 'NATIVE'; state = 'UNRESOLVED'; + error_type = $nativeFailure.GetType().FullName; + native_error = if ($nativeFailure -is [ComponentModel.Win32Exception]) { $nativeFailure.NativeErrorCode } else { $null } }) + $native = $null + } + if ($null -ne $native) { + $reused = [Math]::Abs(($nativeBirth - $birth).Ticks) -gt 10 + if ($reused) { $answer.original_gone = $true } + $answer.observations.Add(@{ source = 'NATIVE'; pid = $native.pid; created_utc = $native.created; + image = $native.exe; live = $native.live; replaces_cim_identity = $reused }) + if (-not $native.live) { + $answer.original_gone = $true; $answer.allowed = $true + $answer.disposition = 'ORIGINAL_GONE'; return $answer + } + if ($nativeBirth.AddTicks(10) -lt $RigCreatedUtc) { + $answer.allowed = $true; $answer.disposition = 'PREDATES_RIG'; return $answer + } + # Image and birth are from ONE retained handle, including a reused PID's new identity. + if ([string]::IsNullOrWhiteSpace($native.exe)) { return $answer } + $answer.allowed = -not (Same-Path $native.exe $RigExe) + $answer.disposition = if ($answer.allowed) { 'OTHER_IMAGE' } else { 'RIG_DAEMON_ALREADY_PRESENT' } + return $answer + } + if ($attempt -ne 0) { return $answer } + try { + $fresh = @(Get-CimInstance Win32_Process -Filter "ProcessId=$($current.ProcessId)" -OperationTimeoutSec (Cap 10) -ErrorAction Stop) + if ($fresh.Count -eq 0) { + # A failed query is not absence; a dead provider cannot certify disappearance. + $control = @(Get-CimInstance Win32_Process -Filter "ProcessId=$PID" -OperationTimeoutSec (Cap 10) -ErrorAction Stop) + if ($control.Count -ne 1 -or $control[0].ProcessId -ne $PID -or $control[0].CreationDate -isnot [DateTime]) { + $answer.disposition = 'SPT_CENSUS_CONTROL_FAILED'; return $answer + } + $answer.observations.Add(@{ source = 'CIM_REREAD'; state = 'ORIGINAL_PAIR_ABSENT'; + pid = $current.ProcessId; created_utc = $birth.ToString('o'); positive_control_pid = $PID }) + $answer.original_gone = $true; $answer.allowed = $true + $answer.disposition = 'ORIGINAL_GONE'; return $answer + } + } catch { + $answer.observations.Add(@{ source = 'CIM_REREAD'; state = 'QUERY_FAILED' }) + $answer.disposition = 'SPT_CENSUS_QUERY_FAILED'; return $answer + } + if ($fresh.Count -ne 1 -or $fresh[0].ProcessId -ne $current.ProcessId) { + $answer.disposition = 'SPT_CENSUS_QUERY_FAILED'; return $answer + } + if ($fresh[0].CreationDate -isnot [DateTime]) { + $answer.disposition = 'SPT_PROCESS_BIRTH_UNREADABLE'; return $answer + } + $sameIdentity = $fresh[0].ProcessId -eq $current.ProcessId -and + $fresh[0].CreationDate.ToUniversalTime().Ticks -eq $birth.Ticks + $answer.observations.Add(@{ source = 'CIM_REREAD'; pid = $fresh[0].ProcessId; + created_utc = $fresh[0].CreationDate.ToUniversalTime().ToString('o'); same_identity = $sameIdentity }) + if (-not $sameIdentity) { $answer.original_gone = $true } + if ($sameIdentity -and [string]::IsNullOrWhiteSpace($fresh[0].ExecutablePath)) { return $answer } + $current = $fresh[0] + } + return $answer } function Start-Native([string]$Label, [string]$Exe, [string[]]$Arguments, [hashtable]$Overlay, [int]$NamedCap, [string]$Scope = 'step') { Seed-Absent @@ -268,10 +395,25 @@ foreach ($path in @($PidPath, $ReadyPath, $StatePath, $LegacyPath, $Rollback, $ProvenancePath, $RetiredPath, (Join-Path $H '.consumed'))) { Assert (-not (Test-Path -LiteralPath $path)) 'RIG_ALREADY_USED_OR_HISTORY_PRESENT' } - $census = @(Get-CimInstance Win32_Process -Filter "Name='spt.exe'" -OperationTimeoutSec (Cap 10)) - foreach ($process in $census) { - Assert (-not [string]::IsNullOrWhiteSpace($process.ExecutablePath)) 'SPT_PROCESS_PATH_UNREADABLE' - Assert (-not (Same-Path $process.ExecutablePath $E)) 'RIG_DAEMON_ALREADY_PRESENT' + $rigCreatedUtc = [DateTimeOffset]::Parse($Stage.creation.created_utc).UtcDateTime + $censusEvidence = @{ rig_created_utc = $rigCreatedUtc.ToString('o'); control_passed = $false; + decisions = [Collections.Generic.List[object]]::new(); completed = $false } + try { + $self = @(Get-CimInstance Win32_Process -Filter "ProcessId=$PID" -OperationTimeoutSec (Cap 10) -ErrorAction Stop) + Assert ($self.Count -eq 1 -and $self[0].ProcessId -eq $PID -and $self[0].CreationDate -is [DateTime]) 'SPT_CENSUS_CONTROL_FAILED' + $censusEvidence.control_passed = $true + $census = @(Get-CimInstance Win32_Process -Filter "Name='spt.exe'" -OperationTimeoutSec (Cap 10) -ErrorAction Stop) + foreach ($process in $census) { + $decision = Get-RigCensusDecision $process $rigCreatedUtc $E + $censusEvidence.decisions.Add($decision) + if (-not $decision.allowed) { + $Failure = "$($decision.disposition) pid=$($decision.pid) birth=$($decision.created_utc)" + throw $Failure + } + } + $censusEvidence.completed = $true + } finally { + New-Json (Join-Path $OutputRoot 'promotion-census.json') $censusEvidence } $Before = @{ applied_state = $null; legacy_applied = $null; daemon_pid = $null; brain_ready = $null; exe = (File-Evidence $E); source_native_identity = $Stage.source.identity; copy_native_identity = $Stage.copy.identity;