diff --git a/docs/phase1-conformance.md b/docs/phase1-conformance.md index fbddb38d..b7151b5e 100644 --- a/docs/phase1-conformance.md +++ b/docs/phase1-conformance.md @@ -740,6 +740,23 @@ toolchain component hashes from that release manifest. The workflow then requires the exact Git, Node, pnpm, rustup, Rust, and Tauri versions before conformance. +When the ordinary Windows test suite reports a non-system null WTS SID, its +failure reporter makes one bounded observation without changing the failure. +The test-only `scripts/windows-process-sid-diagnostics.cs` opens one +query/synchronize handle, performs at most two zero-time waits and one token +query, and closes the handle. Fixed labels and numeric OS codes distinguish +open-not-found, open failure, exited, live readable/unreadable token, invalid +token, and wait failure without emitting a SID. The observation describes the +newly opened handle; it cannot establish continuity or reuse relative to the +original WTS row. Chat #206 remains open until native evidence explains the +ambiguity. Standalone `scripts/windows-process-sid-diagnostics.test.ps1` tests +exercise states and call/cleanup bounds and also run in the native suite. +The native suite additionally checks one real self-process observation through a +nested failure report; terminal-attempt wrapping preserves the inner exception +chain so a real WTS failure reaches the same reporter. The reporter traverses +aggregate children within a twelve-exception total bound and shares one probe +budget across all branches, including producer-plus-quarantine failures. + `scripts/windows-job-supervisor.test.ps1` is also run by the ordinary elevated `windows-2025` supervisor behavior CI job. It creates a real ephemeral standard user and scoped profile/temp/workspace ACLs, launches every supervised probe as @@ -1260,7 +1277,9 @@ The later SDK validator repin must use these exact committed file bytes: | `scripts/phase1-windows-supervisor-build.sh` | 4,646 | `713a9e0282887ade3e243b5ba175794d74cdb02c28c38dcd41491c9505812770` | | `scripts/phase1-windows-supervisor-install.ps1` | 1,743 | `2baab275f0bb6789884cded5f6185d00bfa5348b9e7c3ad1e5575353639101d5` | | `scripts/windows-job-supervisor.cs` | 291,329 | `08c18fa81b16f922b3fac32abec3a2f6369e5f2b9f4caa19a0b48df6302bb110` | -| `scripts/windows-job-supervisor.test.ps1` | 172,760 | `cecc4c4a88ddceff68ab941798a700d2f60e18048be6373044e6c115b08bfcfe` | +| `scripts/windows-job-supervisor.test.ps1` | 175,090 | `8d4ae0914a65f4648523c161c3a212e7d8926bc878ce8e54eeaab102c29b25d7` | +| `scripts/windows-process-sid-diagnostics.cs` | 4,054 | `cd4b1c16a759ce4e63b87c82c4be0dbee9c0b48e9bfd3851eb966c303918e1a2` | +| `scripts/windows-process-sid-diagnostics.test.ps1` | 7,316 | `c83e2d63355fb95c8220045115a3b8106b7507b7132d235ad74eb0283f6c481f` | | `scripts/windows-status-acl-probe.cs` | 6,559 | `aeb7fec2d8becf63b5e94e93d2f8b56cf761ea76d4a714a33f6457a3c65dabe7` | The table above is the SDK-facing subset; `phase1-conformance.lock.json`'s diff --git a/scripts/windows-job-supervisor.test.ps1 b/scripts/windows-job-supervisor.test.ps1 index 27565726..afffd4e7 100644 --- a/scripts/windows-job-supervisor.test.ps1 +++ b/scripts/windows-job-supervisor.test.ps1 @@ -36,17 +36,38 @@ function Write-ExceptionChain { $Failure } $depth = 0 - while ($null -ne $exception -and $depth -lt 12) { + $sidProbeAttempted = $false + $pending = [Collections.Generic.Queue[Exception]]::new() + if ($null -ne $exception) { $pending.Enqueue($exception) } + while ($pending.Count -gt 0 -and $depth -lt 12) { + $exception = $pending.Dequeue() Write-Host "cause[$depth] $($exception.GetType().FullName): $($exception.Message)" + if (-not $sidProbeAttempted -and + $exception.Message -match '^WTS process primary token SID query was ambiguous for process ([0-9]+) in session ([0-9]+)\.$') { + $sidProbeAttempted = $true + # One observation only, after failure; no SID output or acceptance change. + try { + $processId = [uint32]::Parse($Matches[1], [Globalization.CultureInfo]::InvariantCulture) + $queryMethod = [OpenCoven.WindowsJobSupervisor].GetMethod( + 'QueryProcessPrimaryTokenSid', [Reflection.BindingFlags]'NonPublic,Static' + ) + $querySid = [Delegate]::CreateDelegate([Func[IntPtr, string]], $queryMethod) + $observation = [OpenCoven.WindowsProcessSidDiagnostics]::Describe($processId, $querySid) + Write-Host "wts-null-sid-observation: $observation" + } catch { + Write-Host 'wts-null-sid-observation: probe-failed' + } + } + $depth++ if ($exception -is [AggregateException]) { - $index = 0 foreach ($inner in $exception.InnerExceptions) { - Write-Host " aggregate[$index] $($inner.GetType().FullName): $($inner.Message)" - $index++ + if ($pending.Count -ge (12 - $depth)) { break } + $pending.Enqueue($inner) } + } elseif ($null -ne $exception.InnerException -and + $pending.Count -lt (12 - $depth)) { + $pending.Enqueue($exception.InnerException) } - $exception = $exception.InnerException - $depth++ } } @@ -72,6 +93,29 @@ if (-not (Test-Path -LiteralPath $sourcePath -PathType Leaf)) { throw 'Reviewed Windows Job Object supervisor source is missing.' } Add-Type -TypeDefinition ([IO.File]::ReadAllText($sourcePath)) -Language CSharp +& (Join-Path $PSScriptRoot 'windows-process-sid-diagnostics.test.ps1') + +# Exercise the real diagnostic through a nested failure report without failing +# the suite or changing any process. The observed handle is this test process. +$diagnosticFailure = [InvalidOperationException]::new( + 'Synthetic terminal quarantine failure.', + [AggregateException]::new([Exception[]]@( + [InvalidOperationException]::new('Synthetic producer failure.'), + [InvalidOperationException]::new( + "WTS process primary token SID query was ambiguous for process $PID in session 1." + ), + [InvalidOperationException]::new( + "WTS process primary token SID query was ambiguous for process $PID in session 1." + ) + )) +) +$diagnosticOutput = @(Write-ExceptionChain -Failure $diagnosticFailure 6>&1) +$observations = @($diagnosticOutput | ForEach-Object { $_.ToString() } | + Where-Object { $_.StartsWith('wts-null-sid-observation:') }) +if ($observations.Count -ne 1 -or + $observations[0] -cne 'wts-null-sid-observation: live-token-readable') { + throw 'Nested WTS failure reporting did not observe the live test process exactly once.' +} $createProcessWithLogon = [OpenCoven.WindowsJobSupervisor].GetMethod( 'CreateProcessWithLogonW', @@ -4309,7 +4353,9 @@ Start-Sleep -Seconds 300 $directoryQuotas ) } catch { - throw "Terminal failure '$Label' producer attempt failed: $($_.Exception.ToString())" + throw [InvalidOperationException]::new( + "Terminal failure '$Label' producer attempt failed.", $_.Exception + ) } if ( $Mode -eq 'stdout-overflow' -and diff --git a/scripts/windows-process-sid-diagnostics.cs b/scripts/windows-process-sid-diagnostics.cs new file mode 100644 index 00000000..2c7160e2 --- /dev/null +++ b/scripts/windows-process-sid-diagnostics.cs @@ -0,0 +1,104 @@ +using System; +using System.ComponentModel; +using System.Globalization; +using System.Runtime.InteropServices; + +namespace OpenCoven +{ + // Test-only observation: never used to decide quarantine acceptance. + public static class WindowsProcessSidDiagnostics + { + private const int ERROR_INVALID_PARAMETER = 87; + private const int ERROR_NOT_FOUND = 1168; + private const uint WAIT_OBJECT_0 = 0; + private const uint WAIT_TIMEOUT = 258; + private const uint SYNCHRONIZE = 0x00100000; + private const uint PROCESS_QUERY_LIMITED_INFORMATION = 0x1000; + + public static string Describe(uint processId, Func querySid) + { + try + { + return ObserveAmbiguousProcessSid( + processId, + delegate(uint id) + { + return OpenProcess( + SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, false, id); + }, + Marshal.GetLastWin32Error, + delegate(IntPtr process) { return WaitForSingleObject(process, 0); }, + querySid, + delegate(IntPtr process) { CloseHandle(process); }); + } + catch (Exception) + { + return "probe-failed"; + } + } + + // Observations describe only the newly opened handle, not identity continuity + // with the earlier WTS row. They never authorize skipping an ambiguous row. + private static string ObserveAmbiguousProcessSid( + uint processId, + Func openProcess, + Func lastError, + Func waitProcess, + Func querySid, + Action closeProcess) + { + IntPtr process = openProcess(processId); + if (process == IntPtr.Zero) + { + int error = lastError(); + return ((error == ERROR_INVALID_PARAMETER || error == ERROR_NOT_FOUND) + ? "open-not-found:" : "open-failed:") + + error.ToString(CultureInfo.InvariantCulture); + } + try + { + uint wait = waitProcess(process); + if (wait == WAIT_OBJECT_0) return "exited"; + if (wait != WAIT_TIMEOUT) + { + return "wait-failed:" + + lastError().ToString(CultureInfo.InvariantCulture); + } + string tokenState; + try + { + tokenState = String.IsNullOrEmpty(querySid(process)) + ? "live-token-invalid" : "live-token-readable"; + } + catch (Win32Exception error) + { + tokenState = "live-token-unreadable:" + + error.NativeErrorCode.ToString(CultureInfo.InvariantCulture); + } + catch (InvalidOperationException) + { + tokenState = "live-token-invalid"; + } + wait = waitProcess(process); + if (wait == WAIT_OBJECT_0) return "exited-during-query"; + if (wait != WAIT_TIMEOUT) + { + return "wait-failed:" + + lastError().ToString(CultureInfo.InvariantCulture); + } + return tokenState; + } + finally + { + closeProcess(process); + } + } + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr OpenProcess(uint access, bool inherit, uint processId); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern uint WaitForSingleObject(IntPtr process, uint milliseconds); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool CloseHandle(IntPtr process); + } +} diff --git a/scripts/windows-process-sid-diagnostics.test.ps1 b/scripts/windows-process-sid-diagnostics.test.ps1 new file mode 100644 index 00000000..b16e29a5 --- /dev/null +++ b/scripts/windows-process-sid-diagnostics.test.ps1 @@ -0,0 +1,166 @@ +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +if (-not ('OpenCoven.WindowsProcessSidDiagnostics' -as [type])) { + Add-Type -TypeDefinition ([IO.File]::ReadAllText( + (Join-Path $PSScriptRoot 'windows-process-sid-diagnostics.cs') + )) -Language CSharp +} +$observe = [OpenCoven.WindowsProcessSidDiagnostics].GetMethod( + 'ObserveAmbiguousProcessSid', + [Reflection.BindingFlags]'NonPublic,Static' +) +if ($null -eq $observe) { + throw 'Bounded null-SID observation is missing.' +} + +# Throw from a managed delegate directly: a PowerShell scriptblock wraps native +# exceptions and would test PowerShell's adapter instead of the C# boundary. +Add-Type -TypeDefinition @' +public sealed class SidDiagnosticDeniedQuery +{ + public int Calls; + public bool Unexpected; + public string Query(System.IntPtr handle) + { + if (handle != new System.IntPtr(1)) throw new System.Exception("Wrong query handle."); + Calls++; + if (Unexpected) throw new System.Exception("untrusted diagnostic detail"); + throw new System.ComponentModel.Win32Exception(5); + } +} +'@ + +foreach ($case in @( + @{ Name = 'missing'; Open = 0; Error = 87; Wait = 258; Token = 'readable'; Expected = 'open-not-found:87'; Queries = 0; Waits = 0 }, + @{ Name = 'denied'; Open = 0; Error = 5; Wait = 258; Token = 'readable'; Expected = 'open-failed:5'; Queries = 0; Waits = 0 }, + @{ Name = 'exited'; Open = 1; Error = 0; Wait = 0; Token = 'readable'; Expected = 'exited'; Queries = 0; Waits = 1 }, + @{ Name = 'live-readable'; Open = 1; Error = 0; Wait = 258; Token = 'S-1-5-21-private'; Expected = 'live-token-readable'; Queries = 1; Waits = 2 }, + @{ Name = 'live-unreadable'; Open = 1; Error = 5; Wait = 258; Token = 'throw'; Expected = 'live-token-unreadable:5'; Queries = 1; Waits = 2 }, + @{ Name = 'live-empty'; Open = 1; Error = 0; Wait = 258; Token = ''; Expected = 'live-token-invalid'; Queries = 1; Waits = 2 }, + @{ Name = 'wait-failed'; Open = 1; Error = 6; Wait = [uint32]::MaxValue; Token = 'readable'; Expected = 'wait-failed:6'; Queries = 0; Waits = 1 }, + @{ Name = 'second-wait-failed'; Open = 1; Error = 6; Wait = 258; SecondWait = [uint32]::MaxValue; Token = 'readable'; Expected = 'wait-failed:6'; Queries = 1; Waits = 2 }, + @{ Name = 'exit-during-query'; Open = 1; Error = 5; Wait = 258; Token = 'throw'; SecondWait = 0; Expected = 'exited-during-query'; Queries = 1; Waits = 2 } +)) { + $calls = @{ Open = 0; Wait = 0; Query = 0; Close = 0 } + $arguments = [object[]]::new(6) + $arguments[0] = [uint32]123 + $arguments[1] = [Func[uint32, IntPtr]]{ + param($processId) + if ($processId -ne 123) { throw 'Wrong diagnostic PID.' } + $calls.Open++ + return [IntPtr]$case.Open + } + $arguments[2] = [Func[int]]{ return [int]$case.Error } + $arguments[3] = [Func[IntPtr, uint32]]{ + param($handle) + if ($handle -ne [IntPtr]1) { throw 'Wrong wait handle.' } + $calls.Wait++ + if ($calls.Wait -eq 2 -and $case.ContainsKey('SecondWait')) { + return [uint32]$case.SecondWait + } + return [uint32]$case.Wait + } + $arguments[4] = [Func[IntPtr, string]]{ + param($handle) + if ($handle -ne [IntPtr]1) { throw 'Wrong token handle.' } + $calls.Query++ + if ($case.Token -ceq 'throw') { + throw [ComponentModel.Win32Exception]::new([int]$case.Error) + } + return [string]$case.Token + } + $arguments[5] = [Action[IntPtr]]{ + param($handle) + if ($handle -ne [IntPtr]1) { throw 'Wrong close handle.' } + $calls.Close++ + } + $deniedQuery = [SidDiagnosticDeniedQuery]::new() + if ($case.Token -ceq 'throw') { + $arguments[4] = [Delegate]::CreateDelegate( + [Func[IntPtr, string]], $deniedQuery, 'Query' + ) + } + $actual = $observe.Invoke($null, $arguments) + $calls.Query += $deniedQuery.Calls + if ($actual -cne $case.Expected -or $calls.Open -ne 1 -or + $calls.Wait -ne $case.Waits -or $calls.Query -ne $case.Queries -or + $calls.Close -ne $case.Open) { + throw "Bounded SID diagnostic case $($case.Name) failed: $actual; $($calls | ConvertTo-Json -Compress)" + } +} +# Unexpected query failures propagate to the native call site's fixed fallback, +# but must still release the only opened handle. This fixture is independent of +# the matrix case order and its captured delegates. +$unexpectedCalls = @{ Open = 0; Wait = 0; Close = 0 } +$unexpectedQuery = [SidDiagnosticDeniedQuery]::new() +$unexpectedQuery.Unexpected = $true +$unexpectedArguments = [object[]]::new(6) +$unexpectedArguments[0] = [uint32]123 +$unexpectedArguments[1] = [Func[uint32, IntPtr]]{ + param($processId) + if ($processId -ne 123) { throw 'Wrong unexpected-query PID.' } + $unexpectedCalls.Open++ + return [IntPtr]1 +} +$unexpectedArguments[2] = [Func[int]]{ return 0 } +$unexpectedArguments[3] = [Func[IntPtr, uint32]]{ + param($handle) + if ($handle -ne [IntPtr]1) { throw 'Wrong unexpected-query wait handle.' } + $unexpectedCalls.Wait++ + return [uint32]258 +} +$unexpectedArguments[4] = [Delegate]::CreateDelegate( + [Func[IntPtr, string]], $unexpectedQuery, 'Query' +) +$unexpectedArguments[5] = [Action[IntPtr]]{ + param($handle) + if ($handle -ne [IntPtr]1) { throw 'Wrong unexpected-query close handle.' } + $unexpectedCalls.Close++ +} +$unexpectedFailed = $false +try { + $null = $observe.Invoke($null, $unexpectedArguments) +} catch [Management.Automation.MethodInvocationException] { + $underlying = $_.Exception.InnerException + if ($underlying -is [Reflection.TargetInvocationException]) { + $underlying = $underlying.InnerException + } + $unexpectedFailed = $underlying.GetType() -eq [Exception] +} +if (-not $unexpectedFailed -or $unexpectedCalls.Open -ne 1 -or + $unexpectedCalls.Wait -ne 1 -or $unexpectedQuery.Calls -ne 1 -or + $unexpectedCalls.Close -ne 1) { + throw 'Unexpected query failure did not preserve bounded cleanup.' +} +# Execute only the checked-in reporter function, without starting the native +# suite. This verifies aggregate traversal and its shared probe budget on any OS. +$parseTokens = $null +$parseErrors = $null +$reporterFile = [Management.Automation.Language.Parser]::ParseFile( + (Join-Path $PSScriptRoot 'windows-job-supervisor.test.ps1'), + [ref]$parseTokens, [ref]$parseErrors +) +if ($parseErrors.Count -ne 0) { throw 'Native suite parse failed.' } +$reporter = $reporterFile.Find({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -ceq 'Write-ExceptionChain' +}, $false) +if ($null -eq $reporter) { throw 'Failure reporter definition is missing.' } +Invoke-Expression $reporter.Extent.Text +$children = [Collections.Generic.List[Exception]]::new() +$children.Add([Exception]::new('Synthetic producer failure.')) +foreach ($index in 1..2) { + $children.Add([Exception]::new( + "WTS process primary token SID query was ambiguous for process $PID in session 1." + )) +} +foreach ($index in 3..20) { $children.Add([Exception]::new('Synthetic extra failure.')) } +$report = @(Write-ExceptionChain -Failure ([AggregateException]::new($children)) 6>&1 | + ForEach-Object { $_.ToString() }) +if (@($report | Where-Object { $_.StartsWith('cause[') }).Count -ne 12 -or + @($report | Where-Object { $_.StartsWith('wts-null-sid-observation:') }).Count -ne 1) { + throw 'Aggregate reporting exceeded its bounds or missed the sibling ambiguity.' +} +Write-Host 'Bounded null-SID diagnostic cases passed.' diff --git a/src/client-v1-conformance-workflow.test.ts b/src/client-v1-conformance-workflow.test.ts index 795ceb16..0a344c6e 100644 --- a/src/client-v1-conformance-workflow.test.ts +++ b/src/client-v1-conformance-workflow.test.ts @@ -1853,7 +1853,7 @@ ${source.slice(start, end)} 'Unstable SID-wide process drain did not fail closed.', 'Artifact ACL sealing failure did not fail closed.', "Live root '$Label' terminal quarantine failed:", - "Terminal failure '$Label' producer attempt failed:", + `"Terminal failure '$Label' producer attempt failed.", $_.Exception`, "Terminal failure '$Label' cleanup failed:", 'Windows supervisor test cleanup failed:', 'Service creation unexpectedly succeeded for the restricted identity.',