scripts/internal/continuous-co-review/review-windows-runtime-port.ps1
|
$ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest if (-not (Get-Command -Name 'New-SpecrewProcessContainment' -ErrorAction SilentlyContinue)) { . (Join-Path (Split-Path -Parent $PSScriptRoot) 'agent-tasks/process-tree.ps1') } if (-not (Get-Command -Name 'Test-ReviewRuntimeProcessSpec' -ErrorAction SilentlyContinue)) { . (Join-Path $PSScriptRoot 'review-runtime-contract.ps1') } function Initialize-ReviewWindowsJobQueryType { if ('SpecrewReviewJobQueryNative' -as [type]) { return } Add-Type -TypeDefinition @' using System; using System.Runtime.InteropServices; public static class SpecrewReviewJobQueryNative { [DllImport("kernel32.dll", SetLastError = true)] public static extern bool QueryInformationJobObject(IntPtr hJob, int infoClass, IntPtr lpInfo, uint cbInfo, IntPtr lpReturnLength); [StructLayout(LayoutKind.Sequential)] public struct JOBOBJECT_BASIC_ACCOUNTING_INFORMATION { public long TotalUserTime; public long TotalKernelTime; public long ThisPeriodTotalUserTime; public long ThisPeriodTotalKernelTime; public uint TotalPageFaultCount; public uint TotalProcesses; public uint ActiveProcesses; public uint TotalTerminatedProcesses; } public const int JobObjectBasicAccountingInformation = 1; } '@ } function Get-ReviewWindowsJobActiveProcessCount { param([Parameter(Mandatory)][IntPtr]$JobHandle) if (-not $IsWindows -or $JobHandle -eq [IntPtr]::Zero) { return $null } try { Initialize-ReviewWindowsJobQueryType $info = New-Object SpecrewReviewJobQueryNative+JOBOBJECT_BASIC_ACCOUNTING_INFORMATION $size = [Runtime.InteropServices.Marshal]::SizeOf($info) $buffer = [Runtime.InteropServices.Marshal]::AllocHGlobal($size) try { if (-not [SpecrewReviewJobQueryNative]::QueryInformationJobObject($JobHandle, [SpecrewReviewJobQueryNative]::JobObjectBasicAccountingInformation, $buffer, [uint32]$size, [IntPtr]::Zero)) { return $null } $observed = [Runtime.InteropServices.Marshal]::PtrToStructure($buffer, [type]'SpecrewReviewJobQueryNative+JOBOBJECT_BASIC_ACCOUNTING_INFORMATION') return [int]$observed.ActiveProcesses } finally { [Runtime.InteropServices.Marshal]::FreeHGlobal($buffer) } } catch { return $null } } function Test-ReviewWindowsJobObjectAvailability { if (-not $IsWindows) { return [pscustomobject]@{ ok = $false; reason = 'windows-runtime-wrong-platform' } } $job = [IntPtr]::Zero try { Initialize-SpecrewProcessContainmentRuntime $job = [SpecrewJobNative]::CreateJobObject([IntPtr]::Zero, $null) if ($job -eq [IntPtr]::Zero) { return [pscustomobject]@{ ok = $false; reason = 'windows-job-object-create-failed' } } $active = Get-ReviewWindowsJobActiveProcessCount -JobHandle $job if ($null -eq $active) { return [pscustomobject]@{ ok = $false; reason = 'windows-job-object-query-failed' } } return [pscustomobject]@{ ok = $true; reason = 'windows-job-object-ready' } } catch { return [pscustomobject]@{ ok = $false; reason = ('windows-job-object-preflight-failed:' + $_.Exception.Message) } } finally { if ($job -ne [IntPtr]::Zero) { try { $null = [SpecrewJobNative]::CloseHandle($job) } catch { $null = $_ } } } } function Wait-ReviewWindowsJobEmpty { param([Parameter(Mandatory)][IntPtr]$JobHandle, [ValidateRange(100, 30000)][int]$TimeoutMilliseconds = 5000) $watch = [Diagnostics.Stopwatch]::StartNew() while ($watch.ElapsedMilliseconds -lt $TimeoutMilliseconds) { $active = Get-ReviewWindowsJobActiveProcessCount -JobHandle $JobHandle if ($null -eq $active) { return $false } if ($active -eq 0) { return $true } [Threading.Thread]::Sleep(25) } return $false } function Test-ReviewProcessDead { param([Parameter(Mandatory)][int]$ProcessId) try { return $null -eq (Get-Process -Id $ProcessId -ErrorAction SilentlyContinue) } catch { return $true } } function Resolve-ReviewWindowsProcessLaunch { param([Parameter(Mandatory)][string]$CommandName) $commands = @(Get-Command -Name $CommandName -CommandType Application -All -ErrorAction SilentlyContinue) $native = @($commands | Where-Object { [IO.Path]::GetExtension([string]$_.Source) -cin @('.exe', '.com') } | Select-Object -First 1) if ($native.Count -eq 1) { return [pscustomobject]@{ file = [string]$native[0].Source; pre_arguments = @(); resolution = 'native' } } $shim = @($commands | Where-Object { [IO.Path]::GetExtension([string]$_.Source) -cin @('.cmd', '.bat') } | Select-Object -First 1) if ($shim.Count -ne 1) { throw "runtime-command-unavailable:$CommandName" } $shimPath = [IO.Path]::GetFullPath([string]$shim[0].Source) $info = [IO.FileInfo]$shimPath if ($info.Length -gt 8192) { throw "runtime-command-shim-too-large:$CommandName" } $text = [IO.File]::ReadAllText($shimPath, [Text.UTF8Encoding]::new($false, $true)) $match = [regex]::Match($text, '(?im)^\s*%SystemRoot%\\System32\\WindowsPowerShell\\v1\.0\\powershell\.exe\s+-NoProfile\s+-ExecutionPolicy\s+Bypass\s+-File\s+"%SCRIPT_DIR%\\(?<script>[^"\\/]+\.ps1)"\s+%\*\s*$') if (-not $match.Success) { throw "runtime-command-shim-unsupported:$CommandName" } $scriptPath = [IO.Path]::GetFullPath((Join-Path $info.DirectoryName $match.Groups['script'].Value)) if (-not [IO.File]::Exists($scriptPath) -or [IO.Path]::GetDirectoryName($scriptPath) -cne $info.DirectoryName) { throw "runtime-command-shim-target-invalid:$CommandName" } $windowsPowerShell = Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe' if (-not [IO.File]::Exists($windowsPowerShell)) { throw 'runtime-windows-powershell-missing' } return [pscustomobject]@{ file = $windowsPowerShell; pre_arguments = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $scriptPath); resolution = 'bounded-powershell-shim' } } function New-ReviewWindowsRuntimePort { [CmdletBinding()] param( [ValidateRange(1, 7200)][int]$TimeoutSeconds = 900, [ValidateRange(0, 10)][int]$TerminationGraceSeconds = 5, [scriptblock]$CapabilityProbe, [scriptblock]$ContainmentFactory ) if (-not $CapabilityProbe) { $CapabilityProbe = ${function:Test-ReviewWindowsJobObjectAvailability} } if (-not $ContainmentFactory) { $ContainmentFactory = ${function:New-SpecrewProcessContainment} } $validateSpecCommand = ${function:Test-ReviewRuntimeProcessSpec} $initializeContainmentCommand = ${function:Initialize-SpecrewProcessContainmentRuntime} $newContainmentCommand = $ContainmentFactory $stopContainmentCommand = ${function:Stop-SpecrewProcessContainment} $closeContainmentCommand = ${function:Close-SpecrewProcessContainment} $waitJobEmptyCommand = ${function:Wait-ReviewWindowsJobEmpty} $testProcessDeadCommand = ${function:Test-ReviewProcessDead} $getDescendantsCommand = ${function:Get-SpecrewProcessTreeDescendants} $stopTreeCommand = ${function:Stop-SpecrewProcessTree} $resolveLaunchCommand = ${function:Resolve-ReviewWindowsProcessLaunch} $writeProgressCommand = ${function:Write-ReviewRuntimeProgressSample} $testOutputActivityCommand = ${function:Test-ReviewRuntimeOutputActivity} $waitOutputDrainsCommand = ${function:Wait-ReviewRuntimeOutputDrains} $preflight = { param($invocation) $capability = & $CapabilityProbe if (-not $capability.ok) { return $capability } if ($null -eq $invocation -or -not [IO.Directory]::Exists([string]$invocation.snapshot_path)) { return [pscustomobject]@{ ok = $false; reason = 'windows-runtime-snapshot-missing' } } return [pscustomobject]@{ ok = $true; reason = 'windows-job-object-runtime-ready' } }.GetNewClosure() $invoke = { param($harness, $invocation, $onStarted, $environment, $progress) $process = $null; $containment = $null; $spec = $null; $started = $false $stdoutDrain = $null; $stderrDrain = $null try { if ($null -eq $harness -or -not $harness.PSObject.Properties['build_process'] -or $harness.build_process -isnot [scriptblock]) { return [pscustomobject]@{ runtime_outcome = 'launch-failed'; termination_verified = $true; containment = 'unknown'; failure_reason = 'runtime-harness-process-contract-missing'; process_tree_live = $false; output_activity = $false } } $spec = & $harness.build_process $invocation $environment $specValidation = & $validateSpecCommand -Spec $spec -Invocation $invocation if (-not $specValidation.valid) { return [pscustomobject]@{ runtime_outcome = 'launch-failed'; termination_verified = $true; containment = 'unknown'; failure_reason = ('runtime-process-spec-invalid:' + ($specValidation.errors -join ',')); process_tree_live = $false; output_activity = $false } } & $initializeContainmentCommand $launch = & $resolveLaunchCommand -CommandName ([string]$spec.command) $startInfo = [Diagnostics.ProcessStartInfo]::new() $startInfo.FileName = [string]$launch.file foreach ($argument in @($launch.pre_arguments)) { [void]$startInfo.ArgumentList.Add([string]$argument) } foreach ($argument in @($spec.argument_list)) { [void]$startInfo.ArgumentList.Add([string]$argument) } $startInfo.WorkingDirectory = [string]$spec.working_directory $startInfo.UseShellExecute = $false; $startInfo.CreateNoWindow = $true $startInfo.RedirectStandardInput = $true; $startInfo.RedirectStandardOutput = $true; $startInfo.RedirectStandardError = $true $startInfo.StandardInputEncoding = [Text.UTF8Encoding]::new($false) foreach ($key in @($spec.environment_delta.Keys)) { $startInfo.Environment[[string]$key] = [string]$spec.environment_delta[$key] } $process = [Diagnostics.Process]::new(); $process.StartInfo = $startInfo if (-not $process.Start()) { throw 'process-start-returned-false' } $started = $true $stdoutDrain = $process.StandardOutput.BaseStream.CopyToAsync([IO.Stream]::Null) $stderrDrain = $process.StandardError.BaseStream.CopyToAsync([IO.Stream]::Null) $containment = & $newContainmentCommand -ChildPid $process.Id if ($containment.mode -cne 'job-object') { & $stopContainmentCommand -Containment $containment -GraceSeconds 0 [void]$process.WaitForExit(5000) $streamState = & $waitOutputDrainsCommand -StdoutDrain $stdoutDrain -StderrDrain $stderrDrain -TimeoutMilliseconds 5000 $rootDead = & $testProcessDeadCommand -ProcessId $process.Id return [pscustomobject]@{ runtime_outcome = 'containment-violated'; termination_verified = ($streamState.all_closed -and $rootDead) containment = 'violated'; failure_reason = ('windows-job-object-assignment-failed:' + [string]$containment.degraded_reason) process_tree_live = (-not $rootDead); output_activity = [IO.File]::Exists([string]$spec.candidate_result_path) streams_closed = $streamState.all_closed; stdout_stream_closed = $streamState.stdout_closed; stderr_stream_closed = $streamState.stderr_closed } } $runtimeReceipt = [pscustomobject][ordered]@{ schema_version = '1.0'; runtime_id = 'windows-job-object-runtime'; platform = 'windows' containment_kind = 'job-object'; containment_id = ('job-object-process-' + $process.Id) process_id = $process.Id; process_started_at = $process.StartTime.ToUniversalTime().ToString('o') } try { $null = & $onStarted $runtimeReceipt } catch { & $stopContainmentCommand -Containment $containment -GraceSeconds 0 [void]$process.WaitForExit(5000) $streamState = & $waitOutputDrainsCommand -StdoutDrain $stdoutDrain -StderrDrain $stderrDrain -TimeoutMilliseconds 5000 $jobEmpty = if ($containment.mode -ceq 'job-object') { & $waitJobEmptyCommand -JobHandle $containment.job_handle } else { $false } $rootDead = & $testProcessDeadCommand -ProcessId $process.Id $verified = $streamState.all_closed -and $jobEmpty -and $rootDead return [pscustomobject]@{ runtime_outcome = 'abandoned'; termination_verified = $verified containment = $(if ($containment.mode -ceq 'job-object') { 'verified' } else { 'unknown' }) failure_reason = ('runtime-start-callback-failed:' + $_.Exception.Message); process_tree_live = (-not $verified) output_activity = [IO.File]::Exists([string]$spec.candidate_result_path); streams_closed = $streamState.all_closed stdout_stream_closed = $streamState.stdout_closed; stderr_stream_closed = $streamState.stderr_closed } } if ([string]$spec.prompt_transport -ceq 'stdin') { try { $process.StandardInput.Write([string]$spec.stdin_text) } catch { $null = $_ } } try { $process.StandardInput.Close() } catch { $null = $_ } $effectiveTimeout = [Math]::Min($TimeoutSeconds, [int]$spec.timeout_seconds) $timeoutMilliseconds = [long]$effectiveTimeout * 1000 $waitWatch = [Diagnostics.Stopwatch]::StartNew() & $writeProgressCommand -Progress $progress -CandidateResultPath ([string]$spec.candidate_result_path) -ProcessTreeLive $true $exited = $process.HasExited while (-not $exited) { $remaining = $timeoutMilliseconds - $waitWatch.ElapsedMilliseconds if ($remaining -le 0) { $exited = $process.HasExited; break } $slice = [int][Math]::Min(5000, [Math]::Max(1, $remaining)) $exited = $process.WaitForExit($slice) if (-not $exited) { & $writeProgressCommand -Progress $progress -CandidateResultPath ([string]$spec.candidate_result_path) -ProcessTreeLive $true } } $timedOut = -not $exited $exitCode = if ($exited) { $process.ExitCode } else { $null } & $stopContainmentCommand -Containment $containment -GraceSeconds $(if ($timedOut) { $TerminationGraceSeconds } else { 0 }) if (-not $process.HasExited) { [void]$process.WaitForExit(5000) } $streamState = & $waitOutputDrainsCommand -StdoutDrain $stdoutDrain -StderrDrain $stderrDrain -TimeoutMilliseconds 5000 $streamsClosed = $streamState.all_closed $jobEmpty = & $waitJobEmptyCommand -JobHandle $containment.job_handle $rootDead = & $testProcessDeadCommand -ProcessId $process.Id $terminationVerified = $streamsClosed -and $jobEmpty -and $rootDead $outputActivity = & $testOutputActivityCommand -CandidateResultPath ([string]$spec.candidate_result_path) if ($timedOut) { return [pscustomobject]@{ runtime_outcome = 'timed-out'; termination_verified = $terminationVerified; containment = 'verified' failure_reason = $(if ($terminationVerified) { "timeout after $effectiveTimeout seconds; Windows Job Object process tree verified dead and streams closed" } else { "timeout after $effectiveTimeout seconds; termination verification failed" }) process_tree_live = (-not $terminationVerified); output_activity = $outputActivity; streams_closed = $streamsClosed stdout_stream_closed = $streamState.stdout_closed; stderr_stream_closed = $streamState.stderr_closed } } if (-not $terminationVerified) { return [pscustomobject]@{ runtime_outcome = 'abandoned'; termination_verified = $false; containment = 'unknown' failure_reason = 'Windows Job Object termination or stream-closure verification failed' process_tree_live = $true; output_activity = $outputActivity; streams_closed = $streamsClosed stdout_stream_closed = $streamState.stdout_closed; stderr_stream_closed = $streamState.stderr_closed; exit_code = $exitCode } } $outcome = if ($exitCode -eq 0) { 'completed' } else { 'terminated' } return [pscustomobject]@{ runtime_outcome = $outcome; termination_verified = $terminationVerified; containment = 'verified' failure_reason = $(if ($exitCode -eq 0) { $null } else { "reviewer-process-exit-code:$exitCode" }) process_tree_live = (-not $terminationVerified); output_activity = $outputActivity; streams_closed = $streamsClosed stdout_stream_closed = $streamState.stdout_closed; stderr_stream_closed = $streamState.stderr_closed; exit_code = $exitCode } } catch { $why = $_.Exception.Message if (-not $started) { return [pscustomobject]@{ runtime_outcome = 'launch-failed'; termination_verified = $true; containment = 'unknown'; failure_reason = ('runtime-launch-failed:' + $why); process_tree_live = $false; output_activity = $false } } try { $process.StandardInput.Close() } catch { $null = $_ } if ($null -ne $containment) { try { & $stopContainmentCommand -Containment $containment -GraceSeconds 0 } catch { $null = $_ } } if ($null -ne $process) { try { [void]$process.WaitForExit(5000) } catch { $null = $_ } } $streamState = & $waitOutputDrainsCommand -StdoutDrain $stdoutDrain -StderrDrain $stderrDrain -TimeoutMilliseconds 5000 $jobEmpty = if ($null -ne $containment -and $containment.mode -ceq 'job-object') { try { [bool](& $waitJobEmptyCommand -JobHandle $containment.job_handle) } catch { $false } } else { $false } $rootDead = try { & $testProcessDeadCommand -ProcessId $process.Id } catch { $false } $verified = $streamState.all_closed -and $jobEmpty -and $rootDead $outputActivity = if ($null -ne $spec) { & $testOutputActivityCommand -CandidateResultPath ([string]$spec.candidate_result_path) } else { $false } return [pscustomobject]@{ runtime_outcome = 'abandoned'; termination_verified = $verified; containment = $(if ($jobEmpty) { 'verified' } else { 'unknown' }) failure_reason = ('windows-runtime-failed:' + $why); process_tree_live = (-not $verified); output_activity = $outputActivity streams_closed = $streamState.all_closed; stdout_stream_closed = $streamState.stdout_closed; stderr_stream_closed = $streamState.stderr_closed } } finally { if ($null -ne $containment) { try { & $closeContainmentCommand -Containment $containment } catch { $null = $_ } } if ($null -ne $process) { try { $process.Dispose() } catch { $null = $_ } } } }.GetNewClosure() $recover = { param($receipt) if ($null -eq $receipt -or [string]$receipt.runtime_id -cne 'windows-job-object-runtime' -or [string]$receipt.platform -cne 'windows' -or [string]$receipt.containment_kind -cne 'job-object') { return [pscustomobject]@{ termination_verified = $false; containment = 'unknown'; process_tree_live = $null; failure_reason = 'windows-recovery-receipt-mismatch' } } $rootId = [int]$receipt.process_id $process = Get-Process -Id $rootId -ErrorAction SilentlyContinue if ($null -eq $process) { # The verified Job Object was configured KILL_ON_JOB_CLOSE before the receipt was # written. Controller death closes its last handle, so an absent identity proves the # contained tree was reaped without reopening an untrusted PID. return [pscustomobject]@{ termination_verified = $true; containment = 'verified'; process_tree_live = $false; failure_reason = $null } } $observedStart = try { $process.StartTime.ToUniversalTime() } catch { $null } $expectedStart = try { ([DateTimeOffset]$receipt.process_started_at).UtcDateTime } catch { $null } if ($null -eq $observedStart -or $null -eq $expectedStart) { return [pscustomobject]@{ termination_verified = $false; containment = 'unknown'; process_tree_live = $null; failure_reason = 'windows-recovery-process-identity-unreadable' } } if ($observedStart.Ticks -ne $expectedStart.Ticks) { return [pscustomobject]@{ termination_verified = $true; containment = 'verified'; process_tree_live = $false; failure_reason = $null } } $descendants = @(& $getDescendantsCommand -RootPid $rootId) if ($descendants.Count -eq 1 -and $descendants[0] -is [array]) { $descendants = @($descendants[0]) } $observedIds = @($rootId) + @($descendants) & $stopTreeCommand -RootPid $rootId -GraceSeconds $TerminationGraceSeconds $live = @($observedIds | Where-Object { -not (& $testProcessDeadCommand -ProcessId ([int]$_)) }) return [pscustomobject]@{ termination_verified = ($live.Count -eq 0); containment = $(if ($live.Count -eq 0) { 'verified' } else { 'unknown' }) process_tree_live = ($live.Count -gt 0); failure_reason = $(if ($live.Count -eq 0) { $null } else { 'windows-recovery-process-tree-still-live' }) } }.GetNewClosure() return [pscustomobject]@{ id = 'windows-job-object-runtime'; platform = 'windows'; containment = 'job-object'; preflight = $preflight; invoke = $invoke; recover = $recover } } |