diff --git a/probes/windows/09-elevation-uipi.ps1 b/probes/windows/09-elevation-uipi.ps1 new file mode 100644 index 0000000..67dece0 --- /dev/null +++ b/probes/windows/09-elevation-uipi.ps1 @@ -0,0 +1,465 @@ +<# +.SYNOPSIS + Probe 09 (sub-phase 2.0, unit U8): UIPI measured across a real integrity boundary. + +.DESCRIPTION + Engineering Invariant #6 is about a Medium-integrity automation process driving a + High-integrity target. This box cannot produce that pair by ordinary means: the + built-in Administrator (RID 500) runs a full token at S-1-16-12288 and + FilterAdministratorToken is unset, so Admin Approval Mode is off and + "Start-Process -Verb RunAs" yields High-vs-High. runas.exe /trustlevel:0x20000 was + also ruled out - it restricts the token but leaves the mandatory label at High. + The boundary is therefore manufactured with common.ps1's + Start-MediumIntegrityProcess, which lowers a duplicated token to S-1-16-8192 and + asserts the label on read-back. + + Two arms run the SAME worker code against the SAME probe-launched High Notepad; + only the integrity level of the worker differs: + + arm "high" control - worker at S-1-16-12288, injection expected to land + arm "medium" test - worker at S-1-16-8192, injection expected to be dropped + + Effect is never read from SendInput's return value (which is documented to report + success when UIPI drops the input). The parent re-reads the target's edit text over + WM_GETTEXT before and after each arm, High-to-High, as independent observation. +#> +[CmdletBinding()] +param( + [switch]$Worker, + [string]$WorkerOut = '', + [string]$HelperDir = '', + [int]$TargetProcessId = 0, + [string]$TargetHwnd = '0', + [string]$EditHwnd = '0', + [string]$Marker = 'ZZZ', + [string]$Arm = 'unknown' +) + +$ErrorActionPreference = 'Stop' +. "$PSScriptRoot\common.ps1" + +$Probe = '09-elevation-uipi' +$NativeShimSource = @' +using System; +using System.Runtime.InteropServices; + +namespace AgentDesktopProbe { + public static class Native { + [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); + [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid); + [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int cmd); + [DllImport("kernel32.dll", SetLastError = true)] public static extern IntPtr OpenProcess(uint access, bool inherit, int pid); + [DllImport("advapi32.dll", SetLastError = true)] public static extern bool OpenProcessToken(IntPtr proc, uint access, out IntPtr tok); + [DllImport("advapi32.dll", SetLastError = true)] public static extern bool GetTokenInformation(IntPtr tok, int cls, IntPtr buf, int len, out int ret); + [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] public static extern bool ConvertSidToStringSid(IntPtr sid, out IntPtr str); + [DllImport("kernel32.dll", SetLastError = true)] public static extern bool CloseHandle(IntPtr h); + [DllImport("kernel32.dll")] public static extern IntPtr LocalFree(IntPtr h); + + public static int GetForegroundProcessId() { + IntPtr h = GetForegroundWindow(); + if (h == IntPtr.Zero) { return 0; } + uint p = 0; + GetWindowThreadProcessId(h, out p); + return (int)p; + } + + public static string GetIntegritySid(int processId) { + IntPtr proc = OpenProcess(0x1000, false, processId); + if (proc == IntPtr.Zero) { throw new InvalidOperationException("OpenProcess failed for pid " + processId + ", error " + Marshal.GetLastWin32Error()); } + IntPtr tok = IntPtr.Zero; + try { + if (!OpenProcessToken(proc, 0x0008, out tok)) { throw new InvalidOperationException("OpenProcessToken failed, error " + Marshal.GetLastWin32Error()); } + int len = 0; + GetTokenInformation(tok, 25, IntPtr.Zero, 0, out len); + if (len <= 0) { throw new InvalidOperationException("GetTokenInformation sizing failed, error " + Marshal.GetLastWin32Error()); } + IntPtr buf = Marshal.AllocHGlobal(len); + try { + if (!GetTokenInformation(tok, 25, buf, len, out len)) { throw new InvalidOperationException("GetTokenInformation failed, error " + Marshal.GetLastWin32Error()); } + IntPtr str = IntPtr.Zero; + if (!ConvertSidToStringSid(Marshal.ReadIntPtr(buf), out str)) { throw new InvalidOperationException("ConvertSidToStringSid failed, error " + Marshal.GetLastWin32Error()); } + string s = Marshal.PtrToStringUni(str); + LocalFree(str); + return s; + } finally { Marshal.FreeHGlobal(buf); } + } finally { + if (tok != IntPtr.Zero) { CloseHandle(tok); } + CloseHandle(proc); + } + } + } +} +'@ + +$HelperSource = @' +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using System.Text; + +namespace AgentDesktopProbe { + [StructLayout(LayoutKind.Sequential)] + public struct KeybdInput { public ushort wVk; public ushort wScan; public uint dwFlags; public uint time; public IntPtr dwExtraInfo; } + + [StructLayout(LayoutKind.Sequential)] + public struct Input { public uint type; public KeybdInput ki; public int padA; public int padB; } + + public static class U8 { + private delegate bool EnumProc(IntPtr h, IntPtr l); + + [DllImport("user32.dll", SetLastError = true)] private static extern uint SendInput(uint n, Input[] inputs, int size); + [DllImport("user32.dll")] private static extern bool EnumChildWindows(IntPtr parent, EnumProc cb, IntPtr l); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] private static extern int GetClassNameW(IntPtr h, StringBuilder b, int max); + [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode, EntryPoint = "SendMessageTimeoutW")] + private static extern IntPtr SendMessageTimeoutText(IntPtr h, uint msg, IntPtr w, StringBuilder l, uint flags, uint ms, out IntPtr res); + [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode, EntryPoint = "SendMessageTimeoutW")] + private static extern IntPtr SendMessageTimeoutStr(IntPtr h, uint msg, IntPtr w, string l, uint flags, uint ms, out IntPtr res); + [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)] private static extern bool PostMessageW(IntPtr h, uint msg, IntPtr w, IntPtr l); + [DllImport("kernel32.dll")] public static extern IntPtr GetConsoleWindow(); + [DllImport("kernel32.dll")] private static extern void SetLastError(uint code); + + public static int InputStructSize() { return Marshal.SizeOf(typeof(Input)); } + + public static IntPtr FindChildByClass(IntPtr parent, string wanted) { + IntPtr hit = IntPtr.Zero; + EnumChildWindows(parent, delegate(IntPtr h, IntPtr l) { + StringBuilder b = new StringBuilder(256); + GetClassNameW(h, b, 256); + if (String.Equals(b.ToString(), wanted, StringComparison.OrdinalIgnoreCase)) { hit = h; return false; } + return true; + }, IntPtr.Zero); + return hit; + } + + public static string ReadText(IntPtr h, out long sendResult, out int lastError) { + StringBuilder b = new StringBuilder(8192); + IntPtr res; + IntPtr ok = SendMessageTimeoutText(h, 0x000D, (IntPtr)8192, b, 0x0002, 4000, out res); + lastError = Marshal.GetLastWin32Error(); + sendResult = res.ToInt64(); + if (ok == IntPtr.Zero) { return null; } + return b.ToString(); + } + + public static bool WriteText(IntPtr h, string text, out int lastError) { + IntPtr res; + IntPtr ok = SendMessageTimeoutStr(h, 0x000C, IntPtr.Zero, text, 0x0002, 4000, out res); + lastError = Marshal.GetLastWin32Error(); + return ok != IntPtr.Zero; + } + + public static bool PostChar(IntPtr h, char c, out int lastError) { + bool ok = PostMessageW(h, 0x0102, (IntPtr)c, IntPtr.Zero); + lastError = Marshal.GetLastWin32Error(); + return ok; + } + + public static uint SendUnicode(string text, out int lastError) { + List batch = new List(); + for (int i = 0; i < text.Length; i++) { + Input down = new Input(); + down.type = 1; + down.ki.wScan = (ushort)text[i]; + down.ki.dwFlags = 0x0004; + batch.Add(down); + Input up = new Input(); + up.type = 1; + up.ki.wScan = (ushort)text[i]; + up.ki.dwFlags = 0x0004 | 0x0002; + batch.Add(up); + } + Input[] arr = batch.ToArray(); + SetLastError(0); + uint sent = SendInput((uint)arr.Length, arr, Marshal.SizeOf(typeof(Input))); + lastError = Marshal.GetLastWin32Error(); + return sent; + } + } +} +'@ + +function Get-UiaSnapshot { + param([IntPtr]$WindowHandle) + $row = [ordered]@{} + try { + Add-Type -AssemblyName UIAutomationClient -ErrorAction Stop + Add-Type -AssemblyName UIAutomationTypes -ErrorAction Stop + $root = [System.Windows.Automation.AutomationElement]::FromHandle($WindowHandle) + $row['fromHandle'] = 'ok' + $row['name'] = $root.Current.Name + $row['className'] = $root.Current.ClassName + $row['controlType'] = ($root.Current.ControlType.ProgrammaticName -replace '^ControlType\.', '') + $row['ownerProcessId'] = $root.Current.ProcessId + $r = $root.Current.BoundingRectangle + if ($r.IsEmpty -or [double]::IsInfinity($r.Left)) { + $row['boundingRectangle'] = 'empty (window minimized or off-screen)' + } else { + $row['boundingRectangle'] = [ordered]@{ Left = [int]$r.Left; Top = [int]$r.Top; Width = [int]$r.Width; Height = [int]$r.Height } + } + $walker = [System.Windows.Automation.TreeWalker]::RawViewWalker + $stack = New-Object System.Collections.Stack + $stack.Push($root) + $count = 0 + $editValue = $null + $editPatterns = @() + while ($stack.Count -gt 0 -and $count -lt 200) { + $node = $stack.Pop() + $count++ + if ($node.Current.ClassName -eq 'Edit' -and $null -eq $editValue) { + $editPatterns = @($node.GetSupportedPatterns() | ForEach-Object { $_.ProgrammaticName -replace '^(\w+)PatternIdentifiers\.Pattern$', '$1' }) + try { + $vp = $node.GetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern) + $editValue = $vp.Current.Value + } catch { + try { + $tp = $node.GetCurrentPattern([System.Windows.Automation.TextPattern]::Pattern) + $editValue = $tp.DocumentRange.GetText(-1) + } catch { $editValue = '' } + } + } + $child = $walker.GetFirstChild($node) + while ($null -ne $child) { + $stack.Push($child) + $child = $walker.GetNextSibling($child) + } + } + $row['rawViewNodes'] = $count + $row['editPatterns'] = $editPatterns + $row['editValueSeenByThisProcess'] = $editValue + } catch { + $row['fromHandle'] = 'failed' + $row['error'] = $_.Exception.Message + $row['hresult'] = ('0x' + ('{0:x8}' -f $_.Exception.HResult)) + } + return $row +} + +function Invoke-WorkerArm { + $result = [ordered]@{ arm = $Arm; processId = $PID } + Add-Type -Path (Join-Path $HelperDir 'u8native.dll') + Add-Type -Path (Join-Path $HelperDir 'u8helper.dll') + $console = [AgentDesktopProbe.U8]::GetConsoleWindow() + [void][AgentDesktopProbe.Native]::ShowWindow($console, 6) + [void][AgentDesktopProbe.Native]::ShowWindow($console, 0) + $result['addTypeCompilationAvailable'] = $null + try { + Add-Type -TypeDefinition 'public static class U8Compile { public static int V() { return 1; } }' -Language CSharp -ErrorAction Stop + $result['addTypeCompilationAvailable'] = $true + } catch { + $result['addTypeCompilationAvailable'] = $false + $result['addTypeCompilationError'] = $_.Exception.Message + } + $target = [IntPtr][int64]$TargetHwnd + $edit = [IntPtr][int64]$EditHwnd + $result['selfIntegritySid'] = [AgentDesktopProbe.Native]::GetIntegritySid($PID) + try { $result['targetIntegritySid'] = [AgentDesktopProbe.Native]::GetIntegritySid($TargetProcessId) } + catch { $result['targetIntegritySid'] = 'ERROR ' + $_.Exception.Message } + $result['uiaRead'] = Get-UiaSnapshot -WindowHandle $target + + $err = 0 + $raw = [int64]0 + $textBefore = [AgentDesktopProbe.U8]::ReadText($edit, [ref]$raw, [ref]$err) + $result['wmGetTextFromWorker'] = [ordered]@{ returned = $textBefore; sendMessageResult = $raw; lastError = $err } + + [IO.File]::WriteAllText(($WorkerOut + '.ready'), 'ready') + $deadline = (Get-Date).AddSeconds(120) + while ((Get-Date) -lt $deadline -and -not (Test-Path -LiteralPath ($WorkerOut + '.go'))) { Start-Sleep -Milliseconds 200 } + $deadline = (Get-Date).AddSeconds(20) + while ((Get-Date) -lt $deadline -and [AgentDesktopProbe.Native]::GetForegroundProcessId() -ne $TargetProcessId) { + Start-Sleep -Milliseconds 200 + } + $result['foregroundOwnerAtAssert'] = [ordered]@{ processId = [AgentDesktopProbe.Native]::GetForegroundProcessId() } + try { + Assert-Foreground -ExpectedProcessId $TargetProcessId -Stage ('sendinput-pre-' + $Arm) + $result['foregroundAssertPre'] = 'ok' + $sent = [AgentDesktopProbe.U8]::SendUnicode($Marker, [ref]$err) + $result['sendInputEventsAccepted'] = [int]$sent + $result['sendInputLastError'] = $err + $result['sendInputStructSize'] = [AgentDesktopProbe.U8]::InputStructSize() + Start-Sleep -Milliseconds 700 + Assert-Foreground -ExpectedProcessId $TargetProcessId -Stage ('sendinput-post-' + $Arm) + $result['foregroundAssertPost'] = 'ok' + } catch { + $result['foregroundAssertPre'] = 'interference' + $result['interference'] = $_.Exception.Message + } + + $postOk = [AgentDesktopProbe.U8]::PostChar($edit, 'P', [ref]$err) + $result['postMessageWmChar'] = [ordered]@{ returned = $postOk; lastError = $err } + Start-Sleep -Milliseconds 400 + $textAfter = [AgentDesktopProbe.U8]::ReadText($edit, [ref]$raw, [ref]$err) + $result['wmGetTextAfterFromWorker'] = [ordered]@{ returned = $textAfter; sendMessageResult = $raw; lastError = $err } + $result['uiaReadAfter'] = Get-UiaSnapshot -WindowHandle $target + [IO.File]::WriteAllText($WorkerOut, (ConvertTo-Json -InputObject $result -Depth 12), (New-Object System.Text.UTF8Encoding $false)) +} + +if ($Worker) { + try { Invoke-WorkerArm } catch { + [IO.File]::WriteAllText($WorkerOut, (ConvertTo-Json -InputObject ([ordered]@{ arm = $Arm; fatal = $_.Exception.Message }) -Depth 6), (New-Object System.Text.UTF8Encoding $false)) + } + exit 0 +} + +$status = 'ok' +$message = '' +$summary = [ordered]@{} +$script:Spawned = New-Object System.Collections.ArrayList + +try { + Initialize-ProbeNative + $work = Join-Path $env:TEMP 'agent-desktop-u8' + if (-not (Test-Path -LiteralPath $work)) { New-Item -ItemType Directory -Path $work -Force | Out-Null } + Add-Type -TypeDefinition $NativeShimSource -Language CSharp -OutputAssembly (Join-Path $work 'u8native.dll') -OutputType Library + Add-Type -TypeDefinition $HelperSource -Language CSharp -OutputAssembly (Join-Path $work 'u8helper.dll') -OutputType Library + Add-Type -Path (Join-Path $work 'u8helper.dll') + Write-ProbeLog -Message 'compiled u8native.dll + u8helper.dll at High integrity for the Medium worker to load' + + $policy = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' + $uac = [ordered]@{} + foreach ($n in @('EnableLUA', 'FilterAdministratorToken', 'ConsentPromptBehaviorAdmin', 'EnableInstallerDetection')) { + $v = (Get-ItemProperty -Path $policy -Name $n -ErrorAction SilentlyContinue).$n + if ($null -eq $v) { $uac[$n] = '' } else { $uac[$n] = [int]$v } + } + $uac['sessionIntegritySid'] = [AgentDesktopProbe.Native]::GetIntegritySid($PID) + $uac['adminApprovalModeActive'] = ($uac['EnableLUA'] -eq 1 -and $uac['FilterAdministratorToken'] -eq 1) + + $notepad = Start-ScratchProcess -FilePath (Join-Path $env:WINDIR 'System32\notepad.exe') -TimeoutSec 20 + [void]$script:Spawned.Add($notepad.ProcessId) + if ($notepad.MainWindowHandle -eq [IntPtr]::Zero) { throw 'target notepad window never appeared' } + $targetSid = [AgentDesktopProbe.Native]::GetIntegritySid($notepad.ProcessId) + if ($targetSid -ne 'S-1-16-12288') { throw ('target notepad is not High integrity: ' + $targetSid) } + $editHandle = [AgentDesktopProbe.U8]::FindChildByClass($notepad.MainWindowHandle, 'Edit') + if ($editHandle -eq [IntPtr]::Zero) { throw 'notepad Edit child window not found' } + + $arms = @() + $lastErr = 0 + $rawRes = [int64]0 + foreach ($armSpec in @( + [pscustomobject]@{ Name = 'high'; Marker = 'HHH'; Medium = $false }, + [pscustomobject]@{ Name = 'medium'; Marker = 'MMM'; Medium = $true })) { + [void][AgentDesktopProbe.U8]::WriteText($editHandle, 'U8-BASE', [ref]$lastErr) + Start-Sleep -Milliseconds 300 + $before = [AgentDesktopProbe.U8]::ReadText($editHandle, [ref]$rawRes, [ref]$lastErr) + $outFile = Join-Path $work ('arm-' + $armSpec.Name + '.json') + foreach ($stale in @($outFile, ($outFile + '.ready'), ($outFile + '.go'))) { + if (Test-Path -LiteralPath $stale) { Remove-Item -LiteralPath $stale -Force } + } + $argv = @('-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', + '-File', $PSCommandPath, '-Worker', '-WorkerOut', $outFile, '-HelperDir', $work, + '-TargetProcessId', ([string]$notepad.ProcessId), + '-TargetHwnd', ([string]$notepad.MainWindowHandle.ToInt64()), + '-EditHwnd', ([string]$editHandle.ToInt64()), + '-Marker', $armSpec.Marker, '-Arm', $armSpec.Name) + $shell = Join-Path $env:WINDIR 'System32\WindowsPowerShell\v1.0\powershell.exe' + $launchError = '' + $workerPid = 0 + $workerSid = '' + try { + if ($armSpec.Medium) { + $w = Start-MediumIntegrityProcess -FilePath $shell -ArgumentList $argv + $workerPid = $w.ProcessId + $workerSid = $w.IntegritySid + } else { + $w = Start-Process -FilePath $shell -ArgumentList $argv -PassThru + $workerPid = $w.Id + Register-ScratchProcessId -ProcessId $workerPid + $workerSid = [AgentDesktopProbe.Native]::GetIntegritySid($workerPid) + } + [void]$script:Spawned.Add($workerPid) + } catch { + $launchError = $_.Exception.Message + } + if ($armSpec.Medium -and $workerSid -ne 'S-1-16-8192') { + throw ('PROBE-HARNESS: refusing to record a UIPI verdict without a real boundary - medium arm token label is ' + $workerSid + ' ' + $launchError) + } + $handoff = 'worker never signalled ready' + $deadline = (Get-Date).AddSeconds(120) + while ((Get-Date) -lt $deadline -and -not (Test-Path -LiteralPath ($outFile + '.ready'))) { Start-Sleep -Milliseconds 300 } + if (Test-Path -LiteralPath ($outFile + '.ready')) { + [void][AgentDesktopProbe.Native]::ShowWindow($notepad.MainWindowHandle, 6) + Start-Sleep -Milliseconds 500 + [void][AgentDesktopProbe.Native]::ShowWindow($notepad.MainWindowHandle, 9) + $fgDeadline = (Get-Date).AddSeconds(15) + while ((Get-Date) -lt $fgDeadline -and [AgentDesktopProbe.Native]::GetForegroundProcessId() -ne $notepad.ProcessId) { Start-Sleep -Milliseconds 250 } + $observedFg = [AgentDesktopProbe.Native]::GetForegroundProcessId() + $handoff = if ($observedFg -eq $notepad.ProcessId) { 'target foreground restored by parent ShowWindow(SW_MINIMIZE->SW_RESTORE) on the probe-launched window; SetForegroundWindow never called' } + else { 'FAILED - foreground pid ' + $observedFg + ' is not the probe-launched target' } + [IO.File]::WriteAllText(($outFile + '.go'), 'go') + } + $deadline = (Get-Date).AddSeconds(180) + while ((Get-Date) -lt $deadline -and -not (Test-Path -LiteralPath $outFile)) { Start-Sleep -Milliseconds 500 } + $workerData = $null + if (Test-Path -LiteralPath $outFile) { $workerData = (Get-Content -LiteralPath $outFile -Raw | ConvertFrom-Json) } + Start-Sleep -Milliseconds 500 + $after = [AgentDesktopProbe.U8]::ReadText($editHandle, [ref]$rawRes, [ref]$lastErr) + $arms += [ordered]@{ + arm = $armSpec.Name + workerIntegritySid = $workerSid + workerLaunchError = $launchError + marker = $armSpec.Marker + parentForegroundHandoff = $handoff + targetStateBeforeInjection = $before + targetStateAfterInjection = $after + targetStateChanged = ($before -ne $after) + markerPresentInTargetAfterwards = ($null -ne $after -and $after.Contains($armSpec.Marker)) + observedBy = 'parent High-integrity WM_GETTEXT on the notepad Edit child, independent of SendInput return' + worker = $workerData + } + try { Stop-ScratchProcess -ProcessId $workerPid } catch { Write-ProbeLog -Message ('worker teardown: ' + $_.Exception.Message) -Level 'warn' } + } + + $high = $arms | Where-Object { $_.arm -eq 'high' } | Select-Object -First 1 + $medium = $arms | Where-Object { $_.arm -eq 'medium' } | Select-Object -First 1 + $capture = [ordered]@{ + question = 'does UIPI block SendInput and window messages from a Medium-integrity process into a High-integrity target, and are UIA reads still allowed' + invariant = 'Engineering Invariant #6 (UIPI)' + stack = 'managed (UIA reads) + raw Win32 SendInput/SendMessage/PostMessage' + scope = 'api-contract' + boundaryOrigin = 'manufactured by common.ps1 Start-MediumIntegrityProcess (DuplicateTokenEx + SetTokenInformation(TokenIntegrityLevel, S-1-16-8192) + CreateProcessAsUser), label asserted on read-back' + boundaryRuledOut = @( + 'Start-Process -Verb RunAs: AAM off on this box (FilterAdministratorToken unset, RID 500 full token) so it yields High-vs-High', + 'runas.exe /trustlevel:0x20000: restricts the token but leaves the mandatory label at S-1-16-12288' + ) + uacPolicy = $uac + target = [ordered]@{ + process = 'notepad.exe' + processId = $notepad.ProcessId + integritySid = $targetSid + windowHandle = $notepad.MainWindowHandle.ToInt64() + editChild = [ordered]@{ className = 'Edit'; windowHandle = $editHandle.ToInt64() } + launchedByThisProbe = $true + } + arms = $arms + findings = [ordered]@{ + uiaReadFromMediumAgainstHigh = if ($medium -and $medium.worker) { $medium.worker.uiaRead.fromHandle } else { 'unavailable' } + sendInputFromHighLanded = if ($high) { $high.targetStateChanged } else { $null } + sendInputFromMediumLanded = if ($medium) { $medium.targetStateChanged } else { $null } + sendInputReturnIsNotEvidence = 'SendInput reports the event count it accepted in both arms; only the re-read of the target distinguishes them' + wmGetTextFromMediumBlocked = if ($medium -and $medium.worker) { ($null -eq $medium.worker.wmGetTextFromWorker.returned -or $medium.worker.wmGetTextFromWorker.returned -eq '') } else { $null } + } + } + Write-ProbeJson -Probe $Probe -Name 'uipi.json' -InputObject $capture | Out-Null + + $summary['sessionIntegritySid'] = $uac['sessionIntegritySid'] + $summary['targetIntegritySid'] = $targetSid + $summary['mediumArmIntegritySid'] = if ($medium) { $medium.workerIntegritySid } else { '' } + $summary['highArmInjectionLanded'] = $capture.findings.sendInputFromHighLanded + $summary['mediumArmInjectionLanded'] = $capture.findings.sendInputFromMediumLanded + $summary['mediumArmUiaRead'] = $capture.findings.uiaReadFromMediumAgainstHigh + $message = 'uipi measured across a manufactured Medium-vs-High boundary' +} catch { + $status = 'fail' + $message = ($_.Exception.Message -replace '[\r\n]+', ' ') + Write-ProbeLog -Message ('probe failed: ' + $message) -Level 'error' +} finally { + foreach ($id in @($script:Spawned)) { + try { Stop-ScratchProcess -ProcessId $id } catch { Write-ProbeLog -Message ('teardown: ' + $_.Exception.Message) -Level 'warn' } + } + foreach ($f in @(Get-ChildItem -LiteralPath (Get-CaptureDir -Probe $Probe) -File -ErrorAction SilentlyContinue)) { + if ($f.Name -like '*.normalized') { continue } + if (-not (Test-CaptureRedaction -Path $f.FullName)) { $status = 'fail'; $message = ('redaction residue in ' + $f.Name) } + } +} + +Write-ProbeResult -Probe $Probe -Status $status -Message $message -Data $summary +if ($status -eq 'fail') { exit 1 } +exit 0 diff --git a/probes/windows/10-session-dpi.ps1 b/probes/windows/10-session-dpi.ps1 new file mode 100644 index 0000000..02063a5 --- /dev/null +++ b/probes/windows/10-session-dpi.ps1 @@ -0,0 +1,393 @@ +<# +.SYNOPSIS + Probe 10 (sub-phase 2.0, unit U8): session facts and the DPI-awareness bounds delta. + +.DESCRIPTION + Reads the same probe-launched element's BoundingRectangle from a DPI-aware child + process and a DPI-unaware child process, and reports the per-edge numeric delta. + The measurement is taken twice: at the display's recommended scale, and again after + the probe asks for the next scale step up (125%). The original scale is restored in + teardown and the restoration is proved by re-reading it back. + + Delta fields are named deltaLeft/deltaTop/deltaWidth/deltaHeight on purpose. The + KTD9 normalizer buckets any key named exactly x/y/left/top/right/bottom/width/height + to an 8-pixel bucket to absorb layout jitter; a measured delta must not be bucketed + away, and "deltaLeft" has no word boundary before "Left", so it survives verbatim + while the raw rectangles it is derived from are still bucketed. + + Multi-monitor and mixed-DPI behavior is a DEFERRED row: this VM has exactly one + display. It closes at sub-phase 2.4, which owns list_displays and per-monitor + scale_factor. +#> +[CmdletBinding()] +param( + [switch]$Worker, + [string]$WorkerOut = '', + [string]$Mode = 'unaware', + [string]$TargetHwnd = '0', + [string]$EditHwnd = '0' +) + +$ErrorActionPreference = 'Stop' +. "$PSScriptRoot\common.ps1" + +$Probe = '10-session-dpi' +$DpiSource = @' +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using System.Text; + +namespace AgentDesktopProbe { + [StructLayout(LayoutKind.Sequential)] + public struct DcLuid { public uint LowPart; public int HighPart; } + + [StructLayout(LayoutKind.Sequential)] + public struct DcHeader { public int type; public uint size; public DcLuid adapterId; public uint id; } + + [StructLayout(LayoutKind.Sequential)] + public struct DcScaleGet { public DcHeader header; public int minScaleRel; public int curScaleRel; public int maxScaleRel; } + + [StructLayout(LayoutKind.Sequential)] + public struct DcScaleSet { public DcHeader header; public int scaleRel; } + + [StructLayout(LayoutKind.Sequential, Size = 72)] + public struct DcPathInfo { public DcLuid srcAdapterId; public uint srcId; } + + [StructLayout(LayoutKind.Sequential, Size = 64)] + public struct DcModeInfo { public uint infoType; } + + [StructLayout(LayoutKind.Sequential)] + public struct Rect { public int Left; public int Top; public int Right; public int Bottom; } + + public static class Dpi { + private delegate bool EnumProc(IntPtr h, IntPtr l); + + [DllImport("user32.dll")] public static extern int GetDisplayConfigBufferSizes(uint flags, out uint numPath, out uint numMode); + [DllImport("user32.dll")] public static extern int QueryDisplayConfig(uint flags, ref uint numPath, [Out] DcPathInfo[] paths, ref uint numMode, [Out] DcModeInfo[] modes, IntPtr topology); + [DllImport("user32.dll")] public static extern int DisplayConfigGetDeviceInfo(ref DcScaleGet req); + [DllImport("user32.dll")] public static extern int DisplayConfigSetDeviceInfo(ref DcScaleSet req); + [DllImport("user32.dll", SetLastError = true)] public static extern IntPtr SetProcessDpiAwarenessContext(IntPtr context); + [DllImport("user32.dll")] public static extern int GetSystemMetrics(int index); + [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out Rect r); + [DllImport("user32.dll")] public static extern IntPtr MonitorFromPoint(long pt, uint flags); + [DllImport("user32.dll")] public static extern IntPtr GetDC(IntPtr h); + [DllImport("user32.dll")] public static extern int ReleaseDC(IntPtr h, IntPtr dc); + [DllImport("user32.dll")] private static extern bool EnumChildWindows(IntPtr parent, EnumProc cb, IntPtr l); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetClassNameW(IntPtr h, StringBuilder b, int max); + [DllImport("gdi32.dll")] public static extern int GetDeviceCaps(IntPtr dc, int index); + [DllImport("shcore.dll")] public static extern int GetProcessDpiAwareness(IntPtr proc, out int value); + [DllImport("shcore.dll")] public static extern int GetDpiForMonitor(IntPtr mon, int type, out uint dx, out uint dy); + [DllImport("kernel32.dll")] public static extern uint WTSGetActiveConsoleSessionId(); + [DllImport("kernel32.dll")] public static extern uint GetCurrentProcessId(); + [DllImport("kernel32.dll", SetLastError = true)] public static extern bool ProcessIdToSessionId(uint pid, out uint sid); + + public static int LogPixels() { + IntPtr dc = GetDC(IntPtr.Zero); + int v = GetDeviceCaps(dc, 88); + ReleaseDC(IntPtr.Zero, dc); + return v; + } + + public static IntPtr FindChildByClass(IntPtr parent, string wanted) { + IntPtr hit = IntPtr.Zero; + EnumChildWindows(parent, delegate(IntPtr h, IntPtr l) { + StringBuilder b = new StringBuilder(256); + GetClassNameW(h, b, 256); + if (String.Equals(b.ToString(), wanted, StringComparison.OrdinalIgnoreCase)) { hit = h; return false; } + return true; + }, IntPtr.Zero); + return hit; + } + + public static bool GetSourceIds(out DcLuid adapterId, out uint sourceId) { + adapterId = new DcLuid(); + sourceId = 0; + uint numPath = 0; + uint numMode = 0; + if (GetDisplayConfigBufferSizes(2, out numPath, out numMode) != 0) { return false; } + DcPathInfo[] paths = new DcPathInfo[numPath]; + DcModeInfo[] modes = new DcModeInfo[numMode]; + if (QueryDisplayConfig(2, ref numPath, paths, ref numMode, modes, IntPtr.Zero) != 0) { return false; } + if (numPath < 1) { return false; } + adapterId = paths[0].srcAdapterId; + sourceId = paths[0].srcId; + return true; + } + + public static int[] GetScaleRange() { + DcLuid adapter; + uint source; + if (!GetSourceIds(out adapter, out source)) { return new int[] { -999, -999, -999, -999 }; } + DcScaleGet g = new DcScaleGet(); + g.header.type = -3; + g.header.size = (uint)Marshal.SizeOf(typeof(DcScaleGet)); + g.header.adapterId = adapter; + g.header.id = source; + int rc = DisplayConfigGetDeviceInfo(ref g); + return new int[] { rc, g.minScaleRel, g.curScaleRel, g.maxScaleRel }; + } + + public static int SetScaleRelative(int rel) { + DcLuid adapter; + uint source; + if (!GetSourceIds(out adapter, out source)) { return -999; } + DcScaleSet s = new DcScaleSet(); + s.header.type = -4; + s.header.size = (uint)Marshal.SizeOf(typeof(DcScaleSet)); + s.header.adapterId = adapter; + s.header.id = source; + s.scaleRel = rel; + return DisplayConfigSetDeviceInfo(ref s); + } + } +} +'@ + +function Get-AwarenessName { + param([int]$Value) + switch ($Value) { + 0 { return 'PROCESS_DPI_UNAWARE' } + 1 { return 'PROCESS_SYSTEM_DPI_AWARE' } + 2 { return 'PROCESS_PER_MONITOR_DPI_AWARE' } + default { return ('unknown(' + $Value + ')') } + } +} + +function Invoke-DpiWorker { + Add-Type -TypeDefinition $DpiSource -Language CSharp + $row = [ordered]@{ mode = $Mode; processId = [int][AgentDesktopProbe.Dpi]::GetCurrentProcessId() } + $a = 0 + [void][AgentDesktopProbe.Dpi]::GetProcessDpiAwareness([IntPtr]::Zero, [ref]$a) + $row['awarenessAtStartup'] = Get-AwarenessName -Value $a + if ($Mode -eq 'aware') { + $rc = [AgentDesktopProbe.Dpi]::SetProcessDpiAwarenessContext([IntPtr](-4)) + $row['setProcessDpiAwarenessContextPerMonitorV2'] = if ($rc -ne [IntPtr]::Zero) { 'succeeded' } else { 'failed, error ' + [Runtime.InteropServices.Marshal]::GetLastWin32Error() } + } else { + $row['setProcessDpiAwarenessContextPerMonitorV2'] = 'not attempted (unaware arm, forced with __COMPAT_LAYER=DPIUNAWARE)' + } + [void][AgentDesktopProbe.Dpi]::GetProcessDpiAwareness([IntPtr]::Zero, [ref]$a) + $row['awarenessEffective'] = Get-AwarenessName -Value $a + $dx = 0 + $dy = 0 + $mon = [AgentDesktopProbe.Dpi]::MonitorFromPoint(0, 2) + [void][AgentDesktopProbe.Dpi]::GetDpiForMonitor($mon, 0, [ref]$dx, [ref]$dy) + $row['monitorEffectiveDpiX'] = [int]$dx + $row['monitorEffectiveDpiY'] = [int]$dy + $row['logPixels'] = [AgentDesktopProbe.Dpi]::LogPixels() + $row['screenMetricsCx'] = [AgentDesktopProbe.Dpi]::GetSystemMetrics(0) + $row['screenMetricsCy'] = [AgentDesktopProbe.Dpi]::GetSystemMetrics(1) + $target = [IntPtr][int64]$TargetHwnd + $edit = [IntPtr][int64]$EditHwnd + $r = New-Object AgentDesktopProbe.Rect + [void][AgentDesktopProbe.Dpi]::GetWindowRect($target, [ref]$r) + $row['getWindowRect'] = [ordered]@{ Left = $r.Left; Top = $r.Top; Right = $r.Right; Bottom = $r.Bottom } + Add-Type -AssemblyName UIAutomationClient + Add-Type -AssemblyName UIAutomationTypes + foreach ($pair in @(@('window', $target), @('editChild', $edit))) { + $cell = [ordered]@{} + try { + $el = [System.Windows.Automation.AutomationElement]::FromHandle([IntPtr]$pair[1]) + $b = $el.Current.BoundingRectangle + $cell['className'] = $el.Current.ClassName + $cell['Left'] = [int]$b.Left + $cell['Top'] = [int]$b.Top + $cell['Width'] = [int]$b.Width + $cell['Height'] = [int]$b.Height + } catch { $cell['error'] = $_.Exception.Message } + $row[('uiaBounds_' + $pair[0])] = $cell + } + [IO.File]::WriteAllText($WorkerOut, (ConvertTo-Json -InputObject $row -Depth 10), (New-Object System.Text.UTF8Encoding $false)) +} + +if ($Worker) { + try { Invoke-DpiWorker } catch { + [IO.File]::WriteAllText($WorkerOut, (ConvertTo-Json -InputObject ([ordered]@{ mode = $Mode; fatal = $_.Exception.Message }) -Depth 6), (New-Object System.Text.UTF8Encoding $false)) + } + exit 0 +} + +$status = 'ok' +$message = '' +$summary = [ordered]@{} +$script:Spawned = New-Object System.Collections.ArrayList +$script:OriginalScaleRel = $null +$script:WorkDir = Join-Path $env:TEMP 'agent-desktop-u8' + +function Invoke-DpiArm { + param([string]$ArmMode, [string]$Tag, [IntPtr]$Target, [IntPtr]$Edit) + $out = Join-Path $script:WorkDir ('dpi-' + $Tag + '-' + $ArmMode + '.json') + if (Test-Path -LiteralPath $out) { Remove-Item -LiteralPath $out -Force } + $argv = @('-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-ExecutionPolicy', 'Bypass', + '-File', $PSCommandPath, '-Worker', '-WorkerOut', $out, '-Mode', $ArmMode, + '-TargetHwnd', ([string]$Target.ToInt64()), '-EditHwnd', ([string]$Edit.ToInt64())) + if ($ArmMode -eq 'unaware') { $env:__COMPAT_LAYER = 'DPIUNAWARE' } else { $env:__COMPAT_LAYER = $null } + $proc = Start-Process -FilePath (Join-Path $env:WINDIR 'System32\WindowsPowerShell\v1.0\powershell.exe') -ArgumentList $argv -WindowStyle Hidden -PassThru + Register-ScratchProcessId -ProcessId $proc.Id + [void]$script:Spawned.Add($proc.Id) + $env:__COMPAT_LAYER = $null + $deadline = (Get-Date).AddSeconds(120) + while ((Get-Date) -lt $deadline -and -not (Test-Path -LiteralPath $out)) { Start-Sleep -Milliseconds 400 } + try { Stop-ScratchProcess -ProcessId $proc.Id } catch { } + if (-not (Test-Path -LiteralPath $out)) { return [ordered]@{ mode = $ArmMode; fatal = 'worker produced no output' } } + return (Get-Content -LiteralPath $out -Raw | ConvertFrom-Json) +} + +function Get-BoundsDelta { + param($Aware, $Unaware, [string]$Field) + $a = $Aware.$Field + $u = $Unaware.$Field + if ($null -eq $a -or $null -eq $u -or $a.error -or $u.error) { return [ordered]@{ measurable = $false } } + return [ordered]@{ + measurable = $true + deltaLeft = ([int]$a.Left - [int]$u.Left) + deltaTop = ([int]$a.Top - [int]$u.Top) + deltaWidth = ([int]$a.Width - [int]$u.Width) + deltaHeight = ([int]$a.Height - [int]$u.Height) + } +} + +try { + Initialize-ProbeNative + Add-Type -TypeDefinition $DpiSource -Language CSharp + if (-not (Test-Path -LiteralPath $script:WorkDir)) { New-Item -ItemType Directory -Path $script:WorkDir -Force | Out-Null } + + $sid = 0 + [void][AgentDesktopProbe.Dpi]::ProcessIdToSessionId([AgentDesktopProbe.Dpi]::GetCurrentProcessId(), [ref]$sid) + $session = [ordered]@{ + sessionId = [int]$sid + activeConsoleSessionId = [int][AgentDesktopProbe.Dpi]::WTSGetActiveConsoleSessionId() + isRemoteSession = ([AgentDesktopProbe.Dpi]::GetSystemMetrics(0x1000) -ne 0) + windowStation = ([System.Environment]::GetEnvironmentVariable('SESSIONNAME')) + note = 'RDP session-transition behavior is not measurable here (physical console session); it closes at sub-phase 2.1 runner registration' + } + + Add-Type -AssemblyName System.Windows.Forms + $displays = @() + foreach ($s in [System.Windows.Forms.Screen]::AllScreens) { + $displays += [ordered]@{ + primary = $s.Primary + Left = $s.Bounds.Left + Top = $s.Bounds.Top + Width = $s.Bounds.Width + Height = $s.Bounds.Height + } + } + $monitorKeys = @(Get-ChildItem -LiteralPath 'HKCU:\Control Panel\Desktop\PerMonitorSettings' -ErrorAction SilentlyContinue | ForEach-Object { $_.PSChildName }) + $range = [AgentDesktopProbe.Dpi]::GetScaleRange() + $script:OriginalScaleRel = $range[2] + $scale = [ordered]@{ + displayConfigGetResult = $range[0] + scaleRelMin = $range[1] + scaleRelCurrent = $range[2] + scaleRelMax = $range[3] + monitorRegistryKeys = $monitorKeys + stepsAvailableAboveRecommended = ($range[3] - $range[1]) + } + + $notepad = Start-ScratchProcess -FilePath (Join-Path $env:WINDIR 'System32\notepad.exe') -TimeoutSec 20 + [void]$script:Spawned.Add($notepad.ProcessId) + if ($notepad.MainWindowHandle -eq [IntPtr]::Zero) { throw 'dpi target notepad window never appeared' } + $editHandle = [AgentDesktopProbe.Dpi]::FindChildByClass($notepad.MainWindowHandle, 'Edit') + Start-Sleep -Seconds 1 + + $passes = @() + foreach ($step in @( + [pscustomobject]@{ Tag = 'recommended'; Rel = $script:OriginalScaleRel; Wanted = 'display recommended scale (100%)' }, + [pscustomobject]@{ Tag = 'plus-one-step'; Rel = ($script:OriginalScaleRel + 1); Wanted = 'one scale step above recommended (125%)' })) { + $setResult = $null + if ($step.Rel -ne $script:OriginalScaleRel) { + $setResult = [AgentDesktopProbe.Dpi]::SetScaleRelative($step.Rel) + Start-Sleep -Milliseconds 1500 + } + $after = [AgentDesktopProbe.Dpi]::GetScaleRange() + $regValue = $null + if ($monitorKeys.Count -gt 0) { + $regValue = (Get-ItemProperty -Path ('HKCU:\Control Panel\Desktop\PerMonitorSettings\' + $monitorKeys[0]) -Name 'DpiValue' -ErrorAction SilentlyContinue).DpiValue + } + $aware = Invoke-DpiArm -ArmMode 'aware' -Tag $step.Tag -Target $notepad.MainWindowHandle -Edit $editHandle + $unaware = Invoke-DpiArm -ArmMode 'unaware' -Tag $step.Tag -Target $notepad.MainWindowHandle -Edit $editHandle + $passes += [ordered]@{ + pass = $step.Tag + requested = $step.Wanted + requestedScaleRel = $step.Rel + displayConfigSetResult = $setResult + scaleRelReportedAfterwards = $after[2] + registryDpiValueAfterwards = $regValue + effectiveDpiSeenByAwareArm = $aware.monitorEffectiveDpiX + scaleActuallyApplied = ($aware.monitorEffectiveDpiX -ne 96) + awareArm = $aware + unawareArm = $unaware + windowBoundsDelta = (Get-BoundsDelta -Aware $aware -Unaware $unaware -Field 'uiaBounds_window') + editChildBoundsDelta = (Get-BoundsDelta -Aware $aware -Unaware $unaware -Field 'uiaBounds_editChild') + } + } + + $restoreResult = [AgentDesktopProbe.Dpi]::SetScaleRelative($script:OriginalScaleRel) + Start-Sleep -Milliseconds 1500 + $restored = [AgentDesktopProbe.Dpi]::GetScaleRange() + $restoredReg = $null + if ($monitorKeys.Count -gt 0) { + $restoredReg = (Get-ItemProperty -Path ('HKCU:\Control Panel\Desktop\PerMonitorSettings\' + $monitorKeys[0]) -Name 'DpiValue' -ErrorAction SilentlyContinue).DpiValue + } + $teardown = [ordered]@{ + originalScaleRel = $script:OriginalScaleRel + displayConfigSetResult = $restoreResult + scaleRelReadBackAfterwards = $restored[2] + registryDpiValueAfterwards = $restoredReg + restored = ($restored[2] -eq $script:OriginalScaleRel) + } + + $capture = [ordered]@{ + question = 'what bounds delta does DPI awareness produce between two processes reading the same element, and what scale can this environment actually apply' + stack = 'managed UIA for BoundingRectangle; raw Win32 for awareness, DisplayConfig and metrics' + scope = 'api-contract for the awareness APIs; app/provider and environment-specific for the achievable scale' + session = $session + displays = $displays + displayScaleCapability = $scale + method = 'two sibling powershell children read the same probe-launched notepad window and its Edit child; the unaware arm is forced with __COMPAT_LAYER=DPIUNAWARE, the aware arm calls SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)' + normalizationNote = 'raw rectangles are bucketed to 8px by the KTD9 normalizer; delta fields are named deltaLeft/deltaTop/deltaWidth/deltaHeight, which the bucketing regex does not match, so the measured delta survives the normalized twin verbatim' + passes = $passes + teardown = $teardown + deferred = [ordered]@{ + row = 'multi-monitor and mixed-DPI bounds behavior, and any non-zero aware-vs-unaware delta' + reason = 'single display, and that display reports no EDID so Windows offers exactly one scale step (min=cur=max); the 125% request is accepted by DisplayConfigSetDeviceInfo and persisted to the registry but never takes effect' + closesAt = 'sub-phase 2.4 (owns list_displays and per-monitor scale_factor) on a runner with a scalable display' + neverLeaves = 'Phase 2' + } + } + Write-ProbeJson -Probe $Probe -Name 'session-dpi.json' -InputObject $capture | Out-Null + + $summary['sessionId'] = $session.sessionId + $summary['isRemoteSession'] = $session.isRemoteSession + $summary['displayCount'] = $displays.Count + $summary['scaleRelMinCurMax'] = ('' + $scale.scaleRelMin + '/' + $scale.scaleRelCurrent + '/' + $scale.scaleRelMax) + $summary['awarenessPerMonitorV2'] = $passes[0].awareArm.setProcessDpiAwarenessContextPerMonitorV2 + $summary['unawareArmAwareness'] = $passes[0].unawareArm.awarenessEffective + $summary['awareArmAwareness'] = $passes[0].awareArm.awarenessEffective + $summary['windowDeltaAtRecommended'] = ('' + $passes[0].windowBoundsDelta.deltaLeft + ',' + $passes[0].windowBoundsDelta.deltaTop + ',' + $passes[0].windowBoundsDelta.deltaWidth + ',' + $passes[0].windowBoundsDelta.deltaHeight) + $summary['windowDeltaAt125Request'] = ('' + $passes[1].windowBoundsDelta.deltaLeft + ',' + $passes[1].windowBoundsDelta.deltaTop + ',' + $passes[1].windowBoundsDelta.deltaWidth + ',' + $passes[1].windowBoundsDelta.deltaHeight) + $summary['scaleActuallyAppliedAt125Request'] = $passes[1].scaleActuallyApplied + $summary['scaleRestored'] = $teardown.restored + $message = 'session and dpi measured; scale request and restoration both verified by read-back' +} catch { + $status = 'fail' + $message = ($_.Exception.Message -replace '[\r\n]+', ' ') + Write-ProbeLog -Message ('probe failed: ' + $message) -Level 'error' +} finally { + if ($null -ne $script:OriginalScaleRel) { + try { [void][AgentDesktopProbe.Dpi]::SetScaleRelative($script:OriginalScaleRel) } catch { } + } + $env:__COMPAT_LAYER = $null + foreach ($id in @($script:Spawned)) { + try { Stop-ScratchProcess -ProcessId $id } catch { Write-ProbeLog -Message ('teardown: ' + $_.Exception.Message) -Level 'warn' } + } + foreach ($f in @(Get-ChildItem -LiteralPath (Get-CaptureDir -Probe $Probe) -File -ErrorAction SilentlyContinue)) { + if ($f.Name -like '*.normalized') { continue } + if (-not (Test-CaptureRedaction -Path $f.FullName)) { $status = 'fail'; $message = ('redaction residue in ' + $f.Name) } + } +} + +Write-ProbeResult -Probe $Probe -Status $status -Message $message -Data $summary +if ($status -eq 'fail') { exit 1 } +exit 0 diff --git a/probes/windows/11-electron-activation.ps1 b/probes/windows/11-electron-activation.ps1 new file mode 100644 index 0000000..1c185e6 --- /dev/null +++ b/probes/windows/11-electron-activation.ps1 @@ -0,0 +1,337 @@ +<# +.SYNOPSIS + Probe 11 (sub-phase 2.0, unit U8): Chromium/Electron accessibility activation. + +.DESCRIPTION + Grades the committed phases.md claim that Chromium 138+ "exposes a UIA tree to any + UIA client with no flag" against the installed Obsidian build, on both client stacks + (KTD1): managed System.Windows.Automation and the U7 UIA3 COM shim. + + Four Obsidian instances are launched, one per (stack x flag) cell, because the first + UIA client to touch a renderer activates accessibility for that process - reusing one + instance would make the second stack's "first contact" a settled reading. + + The bundled Chromium and Electron versions are read off THIS installation by scanning + Obsidian.exe for the embedded user-agent string. ELECTRON_RUN_AS_NODE=1 with + -p process.versions produces no output on this VM and the framework DLLs carry + component versions, so the UA scan is the determination of record. The activation + verdict is only graded once that version is established. + + Two hazards carried from earlier units, both recorded as probe-placement hazards and + explicitly NOT product claims: + - U3: leaving other windows on top of Obsidian held it at its first-contact node + count through a full settle. Mechanism not isolated. This probe places Obsidian + itself and records the foreground owner at read time. + - U4: matching a bare Chrome_WidgetWin_1 window found an unrelated window and + recorded 9 nodes / 0% coverage as if it were the Electron answer. This probe + matches only windows whose owning pid is one of the launched Obsidian pids. +#> +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +. "$PSScriptRoot\common.ps1" + +$Probe = '11-electron-activation' +$script:Spawned = New-Object System.Collections.ArrayList + +function Initialize-WindowSupport { + if ('AgentDesktopProbe.Win11' -as [type]) { return } + Add-Type -Language CSharp -TypeDefinition @' +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using System.Text; + +namespace AgentDesktopProbe { + public static class Win11 { + private delegate bool EnumProc(IntPtr h, IntPtr l); + [DllImport("user32.dll")] private static extern bool EnumWindows(EnumProc cb, IntPtr l); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] private static extern int GetClassNameW(IntPtr h, StringBuilder b, int max); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] private static extern int GetWindowTextW(IntPtr h, StringBuilder b, int max); + [DllImport("user32.dll")] private static extern bool IsWindowVisible(IntPtr h); + [DllImport("user32.dll")] private static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid); + + public static string ClassOf(IntPtr h) { StringBuilder b = new StringBuilder(256); GetClassNameW(h, b, 256); return b.ToString(); } + public static string TitleOf(IntPtr h) { StringBuilder b = new StringBuilder(512); GetWindowTextW(h, b, 512); return b.ToString(); } + public static int PidOf(IntPtr h) { uint p; GetWindowThreadProcessId(h, out p); return (int)p; } + + public static IntPtr[] VisibleTopLevel() { + List found = new List(); + EnumWindows(delegate(IntPtr h, IntPtr l) { if (IsWindowVisible(h)) { found.Add(h); } return true; }, IntPtr.Zero); + return found.ToArray(); + } + } +} +'@ | Out-Null +} + +function Get-BundledVersions { + param([string]$ExePath) + $rx = New-Object Text.RegularExpressions.Regex 'Chrome/(\d+\.\d+\.\d+\.\d+) Electron/(\d+\.\d+\.\d+)' + $fs = [IO.File]::OpenRead($ExePath) + $buf = New-Object byte[] (8MB) + $prev = '' + $chrome = '' + $electron = '' + try { + while (($n = $fs.Read($buf, 0, 8MB)) -gt 0) { + $text = $prev + [Text.Encoding]::GetEncoding(28591).GetString($buf, 0, $n) + $m = $rx.Match($text) + if ($m.Success) { $chrome = $m.Groups[1].Value; $electron = $m.Groups[2].Value; break } + if ($text.Length -ge 256) { $prev = $text.Substring($text.Length - 256) } else { $prev = $text } + } + } finally { $fs.Close() } + return [ordered]@{ + method = 'user-agent string scanned out of the shipped Obsidian.exe on this installation' + obsidianFileVersion = (Get-Item -LiteralPath $ExePath).VersionInfo.ProductVersion + chromiumVersion = $chrome + electronVersion = $electron + established = ($chrome -ne '' -and $electron -ne '') + rejectedMethods = @( + 'ELECTRON_RUN_AS_NODE=1 Obsidian.exe -p process.versions produced no output on this VM', + 'framework DLL file versions carry component versions, not the Chromium version' + ) + } +} + +function Start-ObsidianInstance { + param([string]$ExePath, [string[]]$ExtraArgs = @()) + Initialize-WindowSupport + if ($ExtraArgs.Count -gt 0) { Start-Process -FilePath $ExePath -ArgumentList $ExtraArgs | Out-Null } + else { Start-Process -FilePath $ExePath | Out-Null } + $deadline = (Get-Date).AddSeconds(75) + while ((Get-Date) -lt $deadline) { + $pids = @(Get-Process -Name 'Obsidian' -ErrorAction SilentlyContinue | ForEach-Object { $_.Id }) + foreach ($id in $pids) { + if (-not $script:Spawned.Contains($id)) { + Register-ScratchProcessId -ProcessId $id + [void]$script:Spawned.Add($id) + } + } + foreach ($h in [AgentDesktopProbe.Win11]::VisibleTopLevel()) { + if ([AgentDesktopProbe.Win11]::ClassOf($h) -ne 'Chrome_WidgetWin_1') { continue } + $owner = [AgentDesktopProbe.Win11]::PidOf($h) + if ($pids -notcontains $owner) { continue } + if (-not [AgentDesktopProbe.Win11]::TitleOf($h)) { continue } + Show-WindowNoActivate -WindowHandle $h -X 0 -Y 0 -Width 1280 -Height 720 + return [pscustomobject]@{ WindowHandle = $h; ProcessId = $owner; AllPids = $pids } + } + Start-Sleep -Milliseconds 700 + } + return $null +} + +function Stop-AllObsidian { + foreach ($p in @(Get-Process -Name 'Obsidian' -ErrorAction SilentlyContinue)) { + try { Stop-Process -Id $p.Id -Force -ErrorAction Stop } catch { } + } + $deadline = (Get-Date).AddSeconds(20) + while ((Get-Date) -lt $deadline) { + if (-not (Get-Process -Name 'Obsidian' -ErrorAction SilentlyContinue)) { return $true } + Start-Sleep -Milliseconds 400 + } + return (-not (Get-Process -Name 'Obsidian' -ErrorAction SilentlyContinue)) +} + +function Measure-ManagedTree { + param([IntPtr]$WindowHandle, [string]$Prefix = '') + Add-Type -AssemblyName UIAutomationClient + Add-Type -AssemblyName UIAutomationTypes + $root = [System.Windows.Automation.AutomationElement]::FromHandle($WindowHandle) + $out = [ordered]@{} + foreach ($view in @('RawView', 'ControlView')) { + $walker = if ($view -eq 'RawView') { [System.Windows.Automation.TreeWalker]::RawViewWalker } else { [System.Windows.Automation.TreeWalker]::ControlViewWalker } + $stack = New-Object System.Collections.Stack + $stack.Push($root) + $count = 0 + $refable = 0 + while ($stack.Count -gt 0 -and $count -lt 8000) { + $node = $stack.Pop() + $count++ + if (@($node.GetSupportedPatterns()).Count -gt 0) { $refable++ } + $child = $walker.GetFirstChild($node) + while ($null -ne $child) { + $stack.Push($child) + $child = $walker.GetNextSibling($child) + } + } + $out[($Prefix + $view + 'Nodes')] = $count + $out[($Prefix + $view + 'RefableNodes')] = $refable + } + return $out +} + +function Invoke-ComShim { + param([string]$ExePath, [string[]]$ShimArgs, [int]$TimeoutSec = 300) + $psi = New-Object System.Diagnostics.ProcessStartInfo + $psi.FileName = $ExePath + $psi.Arguments = ($ShimArgs -join ' ') + $psi.UseShellExecute = $false + $psi.RedirectStandardOutput = $true + $psi.RedirectStandardError = $true + $psi.CreateNoWindow = $true + $proc = [System.Diagnostics.Process]::Start($psi) + Register-ScratchProcessId -ProcessId $proc.Id + [void]$script:Spawned.Add($proc.Id) + $stdout = $proc.StandardOutput.ReadToEnd() + $stderr = $proc.StandardError.ReadToEnd() + if (-not $proc.WaitForExit($TimeoutSec * 1000)) { try { $proc.Kill() } catch { }; throw ('shim ' + $ShimArgs[0] + ' timed out') } + if ($proc.ExitCode -ne 0) { throw ('shim ' + $ShimArgs[0] + ' exited ' + $proc.ExitCode + ': ' + $stderr) } + return ($stdout | ConvertFrom-Json) +} + +$status = 'ok' +$message = '' +$summary = [ordered]@{} + +try { + Initialize-ProbeNative + Initialize-WindowSupport + $obsidianExe = Join-Path $env:LOCALAPPDATA 'Programs\Obsidian\Obsidian.exe' + if (-not (Test-Path -LiteralPath $obsidianExe)) { throw ('Obsidian not installed at ' + $obsidianExe) } + $preexisting = @(Get-Process -Name 'Obsidian' -ErrorAction SilentlyContinue).Count -gt 0 + $versions = Get-BundledVersions -ExePath $obsidianExe + if (-not $versions.established) { throw 'bundled Chromium version could not be read off this installation' } + Write-ProbeLog -Message ('obsidian ' + $versions.obsidianFileVersion + ' bundles chromium ' + $versions.chromiumVersion + ' / electron ' + $versions.electronVersion) + + $csc = Join-Path $env:WINDIR 'Microsoft.NET\Framework64\v4.0.30319\csc.exe' + $buildDir = Join-Path $env:TEMP 'agent-desktop-uia3' + if (-not (Test-Path -LiteralPath $buildDir)) { New-Item -ItemType Directory -Path $buildDir -Force | Out-Null } + $shim = Join-Path $buildDir '08-uia3-com.exe' + $shimOut = (& $csc @('/nologo', '/target:exe', '/langversion:5', '/platform:anycpu', ('/out:' + $shim), + '/reference:System.dll', '/reference:System.Core.dll', (Join-Path $PSScriptRoot '08-uia3-com.cs')) 2>&1 | Out-String).Trim() + if ($LASTEXITCODE -ne 0) { throw ('csc.exe failed building the U7 shim: ' + $shimOut) } + + $cells = @() + foreach ($flagArm in @( + [pscustomobject]@{ Name = 'no-flag'; Args = @() }, + [pscustomobject]@{ Name = 'force-renderer-accessibility'; Args = @('--force-renderer-accessibility') })) { + foreach ($stack in @('managed', 'uia3-com')) { + if (-not (Stop-AllObsidian)) { throw 'could not bring Obsidian to a clean state before an arm' } + $inst = Start-ObsidianInstance -ExePath $obsidianExe -ExtraArgs $flagArm.Args + if ($null -eq $inst) { throw ('Obsidian window never appeared for arm ' + $flagArm.Name + '/' + $stack) } + $cell = [ordered]@{ + flagArm = $flagArm.Name + stack = $stack + authoritative = ($stack -eq 'uia3-com') + windowClassName = [AgentDesktopProbe.Win11]::ClassOf($inst.WindowHandle) + windowHandle = $inst.WindowHandle.ToInt64() + windowHost = [ordered]@{ processId = $inst.ProcessId } + latencyObsidianProcessCountAtWindowDiscovery = $inst.AllPids.Count + foregroundOwner = [ordered]@{ processId = [AgentDesktopProbe.Native]::GetForegroundProcessId() } + settleMs = 8000 + } + if ($stack -eq 'managed') { + $cell['firstContact'] = Measure-ManagedTree -WindowHandle $inst.WindowHandle -Prefix 'latencyFirstContact' + Start-Sleep -Milliseconds 8000 + $cell['afterSettle'] = Measure-ManagedTree -WindowHandle $inst.WindowHandle -Prefix 'settled' + } else { + $walker = Invoke-ComShim -ExePath $shim -ShimArgs @('walker', '--hwnd', ('0x' + $inst.WindowHandle.ToInt64().ToString('x')), + '--label', 'obsidian', '--max-nodes', '20000', '--max-depth', '60', '--settle-ms', '8000') + $fc = [ordered]@{} + foreach ($v in $walker.result.viewsFirstContact) { $fc[('latencyFirstContact' + $v.view + 'Nodes')] = $v.nodeCount } + $st = [ordered]@{} + foreach ($v in $walker.result.viewsAfterSettle) { $st[('settled' + $v.view + 'Nodes')] = $v.nodeCount } + $census = Invoke-ComShim -ExePath $shim -ShimArgs @('census', '--target', ('obsidian=0x' + $inst.WindowHandle.ToInt64().ToString('x')), + '--max-nodes', '20000', '--max-depth', '60') + $t = $census.result.targets[0] + $st['settledRefableNodesWithAnyPattern'] = $t.elementsWithAnyPattern + $st['settledRefableNodesExcludingLegacy'] = $t.elementsWithAnyPatternExcludingLegacy + $cell['firstContact'] = $fc + $cell['afterSettle'] = $st + $cell['censusNote'] = 'census ran in a second shim process against the already-activated instance, so its counts are settled counts; per-element rows are deliberately not captured (R11: Obsidian note titles are content)' + } + $rawFc = $cell.firstContact['latencyFirstContactRawViewNodes'] + $rawSt = $cell.afterSettle['settledRawViewNodes'] + $cell['latencyRawViewGrowthRatio'] = if ($rawFc -gt 0) { [math]::Round(($rawSt / [double]$rawFc), 2) } else { $null } + $cell['activatedWithoutFlag'] = ($flagArm.Name -eq 'no-flag' -and $rawSt -gt $rawFc) + $cells += $cell + Write-ProbeLog -Message ('arm ' + $flagArm.Name + '/' + $stack + ': raw ' + $rawFc + ' -> ' + $rawSt) + } + } + $allGone = Stop-AllObsidian + + $noFlagCom = $cells | Where-Object { $_.flagArm -eq 'no-flag' -and $_.stack -eq 'uia3-com' } | Select-Object -First 1 + $flagCom = $cells | Where-Object { $_.flagArm -ne 'no-flag' -and $_.stack -eq 'uia3-com' } | Select-Object -First 1 + $noFlagManaged = $cells | Where-Object { $_.flagArm -eq 'no-flag' -and $_.stack -eq 'managed' } | Select-Object -First 1 + $flagManaged = $cells | Where-Object { $_.flagArm -ne 'no-flag' -and $_.stack -eq 'managed' } | Select-Object -First 1 + $bothActivate = ($noFlagCom.activatedWithoutFlag -and $noFlagManaged.activatedWithoutFlag) + $flagVerdict = 'ungraded' + if ($versions.established) { + $major = [int]($versions.chromiumVersion -split '\.')[0] + if ($major -lt 138) { + $flagVerdict = 'flag still required - bundled Chromium predates the 138 auto-UIA default' + } elseif ($bothActivate -and $noFlagCom.afterSettle['settledRawViewNodes'] -eq $flagCom.afterSettle['settledRawViewNodes']) { + $flagVerdict = 'partially useful - redundant for eventual tree exposure: on Chromium ' + $versions.chromiumVersion + + ' both client stacks reach the same settled RawView count with no flag, so the phases.md claim holds for exposure. It is not redundant for first-contact readiness: without the flag first contact is deterministically the small pre-activation shell on every run and both stacks, while with the flag first contact is a race against the async tree build and was observed anywhere from a fraction of the settled count to nearly all of it. Neither arm removes the need to settle before trusting a first snapshot.' + } elseif ($bothActivate) { + $flagVerdict = 'redundant for tree exposure - both client stacks get a renderer tree with no flag on Chromium ' + $versions.chromiumVersion + } else { + $flagVerdict = 'still required - a 138+ Chromium did not expose a renderer tree without the flag on at least one stack' + } + } + + $capture = [ordered]@{ + question = 'does Chromium 138+ expose a UIA tree to any UIA client with no flag, and does --force-renderer-accessibility still change anything' + gradedAgainst = 'the committed phases.md line that Chromium 138+ exposes a UIA tree to any UIA client with no flag (P2-O15 / 2.4)' + stack = 'both: managed System.Windows.Automation and the U7 UIA3 COM shim (KTD1 - the COM rows are the authoritative ones)' + scope = 'app/provider - specific to this Obsidian/Electron/Chromium build; the activation mechanism is Chromium behavior, not a Windows API contract' + versions = $versions + obsidianPreexistingAtProbeStart = $preexisting + method = 'one fresh Obsidian instance per (stack x flag) cell, because the first UIA client to touch a renderer activates accessibility for that process' + placementHazards = @( + 'U3: an earlier revision that left other windows on top of Obsidian held it at its first-contact node count through a full 8s settle and a 16s instrumented hold, three runs running. Minimizing the covering windows fixed it. The mechanism was NOT isolated (Chromium native-window occlusion tracking is the obvious candidate, unproven). Carried here as a probe-placement hazard, explicitly not a product claim.', + 'U4: matching a bare Chrome_WidgetWin_1 window found an unrelated window and recorded 9 nodes / 0% coverage as if it were the Electron answer. This probe only accepts a window whose owning pid is one of the launched Obsidian pids, and reads the hosting pid back off the window.' + ) + countStabilityNote = 'settled node counts are content-dependent (U7 measured RawView 172 on a bare walker run, U3 measured 119 through its probe). The stable fact is the growth ratio across the settle, not the absolute count.' + cells = $cells + findings = [ordered]@{ + managedActivatesWithoutFlag = $noFlagManaged.activatedWithoutFlag + comActivatesWithoutFlag = $noFlagCom.activatedWithoutFlag + clientStackDivergence = ($noFlagManaged.activatedWithoutFlag -ne $noFlagCom.activatedWithoutFlag) + latencyComRawViewNoFlagFirstContact = $noFlagCom.firstContact['latencyFirstContactRawViewNodes'] + latencyComRawViewWithFlagFirstContact = $flagCom.firstContact['latencyFirstContactRawViewNodes'] + latencyManagedRawViewNoFlagFirstContact = $noFlagManaged.firstContact['latencyFirstContactRawViewNodes'] + latencyManagedRawViewWithFlagFirstContact = $flagManaged.firstContact['latencyFirstContactRawViewNodes'] + settledComRawViewNoFlag = $noFlagCom.afterSettle['settledRawViewNodes'] + settledComRawViewWithFlag = $flagCom.afterSettle['settledRawViewNodes'] + settledManagedRawViewNoFlag = $noFlagManaged.afterSettle['settledRawViewNodes'] + settledManagedRawViewWithFlag = $flagManaged.afterSettle['settledRawViewNodes'] + settledCountsMatchAcrossFlagArms = ($noFlagCom.afterSettle['settledRawViewNodes'] -eq $flagCom.afterSettle['settledRawViewNodes'] -and $noFlagManaged.afterSettle['settledRawViewNodes'] -eq $flagManaged.afterSettle['settledRawViewNodes']) + firstContactCountsAreRunVarying = 'first-contact counts are a race against Chromium async tree construction and are named latency* so the KTD9 normalizer canonicalizes them; the raw samples stay in this capture, the settled counts are the stable evidence' + flagVerdict = $flagVerdict + supersededPreFinding = 'the plan pre-finding (8 managed descendants, stable at 2/5/10s, candidate CONTRADICTS) is superseded: it was measured through an occluded window with a managed client, and neither the occlusion nor the settle window was controlled' + } + teardown = [ordered]@{ allObsidianProcessesTerminated = $allGone } + } + Write-ProbeJson -Probe $Probe -Name 'electron-activation.json' -InputObject $capture | Out-Null + + $summary['chromiumVersion'] = $versions.chromiumVersion + $summary['electronVersion'] = $versions.electronVersion + $summary['obsidianVersion'] = $versions.obsidianFileVersion + $summary['comRawViewNoFlag'] = ('' + $capture.findings.latencyComRawViewNoFlagFirstContact + ' -> ' + $capture.findings.settledComRawViewNoFlag) + $summary['comRawViewWithFlag'] = ('' + $capture.findings.latencyComRawViewWithFlagFirstContact + ' -> ' + $capture.findings.settledComRawViewWithFlag) + $summary['managedRawViewNoFlag'] = ('' + $capture.findings.latencyManagedRawViewNoFlagFirstContact + ' -> ' + $capture.findings.settledManagedRawViewNoFlag) + $summary['managedRawViewWithFlag'] = ('' + $capture.findings.latencyManagedRawViewWithFlagFirstContact + ' -> ' + $capture.findings.settledManagedRawViewWithFlag) + $summary['clientStackDivergence'] = $capture.findings.clientStackDivergence + $summary['flagVerdict'] = $flagVerdict + $message = 'electron activation measured on both stacks with and without the flag' +} catch { + $status = 'fail' + $message = ($_.Exception.Message -replace '[\r\n]+', ' ') + Write-ProbeLog -Message ('probe failed: ' + $message) -Level 'error' +} finally { + [void](Stop-AllObsidian) + foreach ($id in @($script:Spawned)) { + try { Stop-ScratchProcess -ProcessId $id } catch { Write-ProbeLog -Message ('teardown: ' + $_.Exception.Message) -Level 'warn' } + } + foreach ($f in @(Get-ChildItem -LiteralPath (Get-CaptureDir -Probe $Probe) -File -ErrorAction SilentlyContinue)) { + if ($f.Name -like '*.normalized') { continue } + if (-not (Test-CaptureRedaction -Path $f.FullName)) { $status = 'fail'; $message = ('redaction residue in ' + $f.Name) } + } +} + +Write-ProbeResult -Probe $Probe -Status $status -Message $message -Data $summary +if ($status -eq 'fail') { exit 1 } +exit 0 diff --git a/probes/windows/captures/09-elevation-uipi/uipi.json b/probes/windows/captures/09-elevation-uipi/uipi.json new file mode 100644 index 0000000..b3fd9b2 --- /dev/null +++ b/probes/windows/captures/09-elevation-uipi/uipi.json @@ -0,0 +1,194 @@ +{ + "question": "does UIPI block SendInput and window messages from a Medium-integrity process into a High-integrity target, and are UIA reads still allowed", + "invariant": "Engineering Invariant #6 (UIPI)", + "stack": "managed (UIA reads) + raw Win32 SendInput/SendMessage/PostMessage", + "scope": "api-contract", + "boundaryOrigin": "manufactured by common.ps1 Start-MediumIntegrityProcess (DuplicateTokenEx + SetTokenInformation(TokenIntegrityLevel, S-1-16-8192) + CreateProcessAsUser), label asserted on read-back", + "boundaryRuledOut": [ + "Start-Process -Verb RunAs: AAM off on this box (FilterAdministratorToken unset, RID 500 full token) so it yields High-vs-High", + "runas.exe /trustlevel:0x20000: restricts the token but leaves the mandatory label at S-1-16-12288" + ], + "uacPolicy": { + "EnableLUA": 1, + "FilterAdministratorToken": "\u003cvalue not present\u003e", + "ConsentPromptBehaviorAdmin": 5, + "EnableInstallerDetection": 1, + "sessionIntegritySid": "S-1-16-12288", + "adminApprovalModeActive": false + }, + "target": { + "process": "notepad.exe", + "processId": 7156, + "integritySid": "S-1-16-12288", + "windowHandle": 6948082, + "editChild": { + "className": "Edit", + "windowHandle": 5243138 + }, + "launchedByThisProbe": true + }, + "arms": [ + { + "arm": "high", + "workerIntegritySid": "S-1-16-12288", + "workerLaunchError": "", + "marker": "HHH", + "parentForegroundHandoff": "target foreground restored by parent ShowWindow(SW_MINIMIZE-\u003eSW_RESTORE) on the probe-launched window; SetForegroundWindow never called", + "targetStateBeforeInjection": "U8-BASE", + "targetStateAfterInjection": "HHHPU8-BASE", + "targetStateChanged": true, + "markerPresentInTargetAfterwards": true, + "observedBy": "parent High-integrity WM_GETTEXT on the notepad Edit child, independent of SendInput return", + "worker": { + "arm": "high", + "processId": 7916, + "addTypeCompilationAvailable": true, + "selfIntegritySid": "S-1-16-12288", + "targetIntegritySid": "S-1-16-12288", + "uiaRead": { + "fromHandle": "ok", + "name": "Untitled - Notepad", + "className": "Notepad", + "controlType": "Window", + "ownerProcessId": 7156, + "boundingRectangle": { + "Left": 476, + "Top": 100, + "Width": 768, + "Height": 525 + }, + "rawViewNodes": 3, + "editPatterns": [ + + ], + "editValueSeenByThisProcess": "\u003cno value/text pattern: Exception calling \"GetCurrentPattern\" with \"1\" argument(s): \"Unsupported Pattern.\"\u003e" + }, + "wmGetTextFromWorker": { + "returned": "U8-BASE", + "sendMessageResult": 7, + "lastError": 0 + }, + "foregroundOwnerAtAssert": { + "processId": 7156 + }, + "foregroundAssertPre": "ok", + "sendInputEventsAccepted": 6, + "sendInputLastError": 0, + "sendInputStructSize": 40, + "foregroundAssertPost": "ok", + "postMessageWmChar": { + "returned": true, + "lastError": 203 + }, + "wmGetTextAfterFromWorker": { + "returned": "HHHPU8-BASE", + "sendMessageResult": 11, + "lastError": 0 + }, + "uiaReadAfter": { + "fromHandle": "ok", + "name": "Untitled - Notepad", + "className": "Notepad", + "controlType": "Window", + "ownerProcessId": 7156, + "boundingRectangle": { + "Left": 476, + "Top": 100, + "Width": 768, + "Height": 525 + }, + "rawViewNodes": 3, + "editPatterns": [ + + ], + "editValueSeenByThisProcess": "\u003cno value/text pattern: Exception calling \"GetCurrentPattern\" with \"1\" argument(s): \"Unsupported Pattern.\"\u003e" + } + } + }, + { + "arm": "medium", + "workerIntegritySid": "S-1-16-8192", + "workerLaunchError": "", + "marker": "MMM", + "parentForegroundHandoff": "target foreground restored by parent ShowWindow(SW_MINIMIZE-\u003eSW_RESTORE) on the probe-launched window; SetForegroundWindow never called", + "targetStateBeforeInjection": "U8-BASE", + "targetStateAfterInjection": "U8-BASE", + "targetStateChanged": false, + "markerPresentInTargetAfterwards": false, + "observedBy": "parent High-integrity WM_GETTEXT on the notepad Edit child, independent of SendInput return", + "worker": { + "arm": "medium", + "processId": 7508, + "addTypeCompilationAvailable": false, + "addTypeCompilationError": "A required privilege is not held by the client", + "selfIntegritySid": "S-1-16-8192", + "targetIntegritySid": "S-1-16-12288", + "uiaRead": { + "fromHandle": "ok", + "name": "Untitled - Notepad", + "className": "Notepad", + "controlType": "Window", + "ownerProcessId": 7156, + "boundingRectangle": { + "Left": 476, + "Top": 100, + "Width": 768, + "Height": 525 + }, + "rawViewNodes": 3, + "editPatterns": [ + + ], + "editValueSeenByThisProcess": "\u003cno value/text pattern: Exception calling \"GetCurrentPattern\" with \"1\" argument(s): \"Unsupported Pattern.\"\u003e" + }, + "wmGetTextFromWorker": { + "returned": "U8-BASE", + "sendMessageResult": 7, + "lastError": 0 + }, + "foregroundOwnerAtAssert": { + "processId": 7156 + }, + "foregroundAssertPre": "ok", + "sendInputEventsAccepted": 6, + "sendInputLastError": 0, + "sendInputStructSize": 40, + "foregroundAssertPost": "ok", + "postMessageWmChar": { + "returned": false, + "lastError": 5 + }, + "wmGetTextAfterFromWorker": { + "returned": "U8-BASE", + "sendMessageResult": 7, + "lastError": 0 + }, + "uiaReadAfter": { + "fromHandle": "ok", + "name": "Untitled - Notepad", + "className": "Notepad", + "controlType": "Window", + "ownerProcessId": 7156, + "boundingRectangle": { + "Left": 476, + "Top": 100, + "Width": 768, + "Height": 525 + }, + "rawViewNodes": 3, + "editPatterns": [ + + ], + "editValueSeenByThisProcess": "\u003cno value/text pattern: Exception calling \"GetCurrentPattern\" with \"1\" argument(s): \"Unsupported Pattern.\"\u003e" + } + } + } + ], + "findings": { + "uiaReadFromMediumAgainstHigh": "ok", + "sendInputFromHighLanded": true, + "sendInputFromMediumLanded": false, + "sendInputReturnIsNotEvidence": "SendInput reports the event count it accepted in both arms; only the re-read of the target distinguishes them", + "wmGetTextFromMediumBlocked": false + } +} \ No newline at end of file diff --git a/probes/windows/captures/09-elevation-uipi/uipi.json.normalized b/probes/windows/captures/09-elevation-uipi/uipi.json.normalized new file mode 100644 index 0000000..b9aca38 --- /dev/null +++ b/probes/windows/captures/09-elevation-uipi/uipi.json.normalized @@ -0,0 +1,194 @@ +{ + "question": "does UIPI block SendInput and window messages from a Medium-integrity process into a High-integrity target, and are UIA reads still allowed", + "invariant": "Engineering Invariant #6 (UIPI)", + "stack": "managed (UIA reads) + raw Win32 SendInput/SendMessage/PostMessage", + "scope": "api-contract", + "boundaryOrigin": "manufactured by common.ps1 Start-MediumIntegrityProcess (DuplicateTokenEx + SetTokenInformation(TokenIntegrityLevel, S-1-16-8192) + CreateProcessAsUser), label asserted on read-back", + "boundaryRuledOut": [ + "Start-Process -Verb RunAs: AAM off on this box (FilterAdministratorToken unset, RID 500 full token) so it yields High-vs-High", + "runas.exe /trustlevel:0x20000: restricts the token but leaves the mandatory label at S-1-16-12288" + ], + "uacPolicy": { + "EnableLUA": 1, + "FilterAdministratorToken": "\u003cvalue not present\u003e", + "ConsentPromptBehaviorAdmin": 5, + "EnableInstallerDetection": 1, + "sessionIntegritySid": "S-1-16-12288", + "adminApprovalModeActive": false + }, + "target": { + "process": "notepad.exe", + "processId": , + "integritySid": "S-1-16-12288", + "windowHandle": , + "editChild": { + "className": "Edit", + "windowHandle": + }, + "launchedByThisProbe": true + }, + "arms": [ + { + "arm": "high", + "workerIntegritySid": "S-1-16-12288", + "workerLaunchError": "", + "marker": "HHH", + "parentForegroundHandoff": "target foreground restored by parent ShowWindow(SW_MINIMIZE-\u003eSW_RESTORE) on the probe-launched window; SetForegroundWindow never called", + "targetStateBeforeInjection": "U8-BASE", + "targetStateAfterInjection": "HHHPU8-BASE", + "targetStateChanged": true, + "markerPresentInTargetAfterwards": true, + "observedBy": "parent High-integrity WM_GETTEXT on the notepad Edit child, independent of SendInput return", + "worker": { + "arm": "high", + "processId": , + "addTypeCompilationAvailable": true, + "selfIntegritySid": "S-1-16-12288", + "targetIntegritySid": "S-1-16-12288", + "uiaRead": { + "fromHandle": "ok", + "name": "Untitled - Notepad", + "className": "Notepad", + "controlType": "Window", + "ownerProcessId": , + "boundingRectangle": { + "Left": 480, + "Top": 96, + "Width": 768, + "Height": 528 + }, + "rawViewNodes": 3, + "editPatterns": [ + + ], + "editValueSeenByThisProcess": "\u003cno value/text pattern: Exception calling \"GetCurrentPattern\" with \"1\" argument(s): \"Unsupported Pattern.\"\u003e" + }, + "wmGetTextFromWorker": { + "returned": "U8-BASE", + "sendMessageResult": 7, + "lastError": 0 + }, + "foregroundOwnerAtAssert": { + "processId": + }, + "foregroundAssertPre": "ok", + "sendInputEventsAccepted": 6, + "sendInputLastError": 0, + "sendInputStructSize": 40, + "foregroundAssertPost": "ok", + "postMessageWmChar": { + "returned": true, + "lastError": 203 + }, + "wmGetTextAfterFromWorker": { + "returned": "HHHPU8-BASE", + "sendMessageResult": 11, + "lastError": 0 + }, + "uiaReadAfter": { + "fromHandle": "ok", + "name": "Untitled - Notepad", + "className": "Notepad", + "controlType": "Window", + "ownerProcessId": , + "boundingRectangle": { + "Left": 480, + "Top": 96, + "Width": 768, + "Height": 528 + }, + "rawViewNodes": 3, + "editPatterns": [ + + ], + "editValueSeenByThisProcess": "\u003cno value/text pattern: Exception calling \"GetCurrentPattern\" with \"1\" argument(s): \"Unsupported Pattern.\"\u003e" + } + } + }, + { + "arm": "medium", + "workerIntegritySid": "S-1-16-8192", + "workerLaunchError": "", + "marker": "MMM", + "parentForegroundHandoff": "target foreground restored by parent ShowWindow(SW_MINIMIZE-\u003eSW_RESTORE) on the probe-launched window; SetForegroundWindow never called", + "targetStateBeforeInjection": "U8-BASE", + "targetStateAfterInjection": "U8-BASE", + "targetStateChanged": false, + "markerPresentInTargetAfterwards": false, + "observedBy": "parent High-integrity WM_GETTEXT on the notepad Edit child, independent of SendInput return", + "worker": { + "arm": "medium", + "processId": , + "addTypeCompilationAvailable": false, + "addTypeCompilationError": "A required privilege is not held by the client", + "selfIntegritySid": "S-1-16-8192", + "targetIntegritySid": "S-1-16-12288", + "uiaRead": { + "fromHandle": "ok", + "name": "Untitled - Notepad", + "className": "Notepad", + "controlType": "Window", + "ownerProcessId": , + "boundingRectangle": { + "Left": 480, + "Top": 96, + "Width": 768, + "Height": 528 + }, + "rawViewNodes": 3, + "editPatterns": [ + + ], + "editValueSeenByThisProcess": "\u003cno value/text pattern: Exception calling \"GetCurrentPattern\" with \"1\" argument(s): \"Unsupported Pattern.\"\u003e" + }, + "wmGetTextFromWorker": { + "returned": "U8-BASE", + "sendMessageResult": 7, + "lastError": 0 + }, + "foregroundOwnerAtAssert": { + "processId": + }, + "foregroundAssertPre": "ok", + "sendInputEventsAccepted": 6, + "sendInputLastError": 0, + "sendInputStructSize": 40, + "foregroundAssertPost": "ok", + "postMessageWmChar": { + "returned": false, + "lastError": 5 + }, + "wmGetTextAfterFromWorker": { + "returned": "U8-BASE", + "sendMessageResult": 7, + "lastError": 0 + }, + "uiaReadAfter": { + "fromHandle": "ok", + "name": "Untitled - Notepad", + "className": "Notepad", + "controlType": "Window", + "ownerProcessId": , + "boundingRectangle": { + "Left": 480, + "Top": 96, + "Width": 768, + "Height": 528 + }, + "rawViewNodes": 3, + "editPatterns": [ + + ], + "editValueSeenByThisProcess": "\u003cno value/text pattern: Exception calling \"GetCurrentPattern\" with \"1\" argument(s): \"Unsupported Pattern.\"\u003e" + } + } + } + ], + "findings": { + "uiaReadFromMediumAgainstHigh": "ok", + "sendInputFromHighLanded": true, + "sendInputFromMediumLanded": false, + "sendInputReturnIsNotEvidence": "SendInput reports the event count it accepted in both arms; only the re-read of the target distinguishes them", + "wmGetTextFromMediumBlocked": false + } +} \ No newline at end of file diff --git a/probes/windows/captures/10-session-dpi/session-dpi.json b/probes/windows/captures/10-session-dpi/session-dpi.json new file mode 100644 index 0000000..e822901 --- /dev/null +++ b/probes/windows/captures/10-session-dpi/session-dpi.json @@ -0,0 +1,224 @@ +{ + "question": "what bounds delta does DPI awareness produce between two processes reading the same element, and what scale can this environment actually apply", + "stack": "managed UIA for BoundingRectangle; raw Win32 for awareness, DisplayConfig and metrics", + "scope": "api-contract for the awareness APIs; app/provider and environment-specific for the achievable scale", + "session": { + "sessionId": 1, + "activeConsoleSessionId": 1, + "isRemoteSession": false, + "windowStation": "Console", + "note": "RDP session-transition behavior is not measurable here (physical console session); it closes at sub-phase 2.1 runner registration" + }, + "displays": [ + { + "primary": true, + "Left": 0, + "Top": 0, + "Width": 1639, + "Height": 732 + } + ], + "displayScaleCapability": { + "displayConfigGetResult": 0, + "scaleRelMin": 0, + "scaleRelCurrent": 0, + "scaleRelMax": 0, + "monitorRegistryKeys": [ + "NOEDID_15AD_0405_00000000_000F0000_0^20ED182961F2CFDB3A2D28C95A99744F" + ], + "stepsAvailableAboveRecommended": 0 + }, + "method": "two sibling powershell children read the same probe-launched notepad window and its Edit child; the unaware arm is forced with __COMPAT_LAYER=DPIUNAWARE, the aware arm calls SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)", + "normalizationNote": "raw rectangles are bucketed to 8px by the KTD9 normalizer; delta fields are named deltaLeft/deltaTop/deltaWidth/deltaHeight, which the bucketing regex does not match, so the measured delta survives the normalized twin verbatim", + "passes": [ + { + "pass": "recommended", + "requested": "display recommended scale (100%)", + "requestedScaleRel": 0, + "displayConfigSetResult": null, + "scaleRelReportedAfterwards": 0, + "registryDpiValueAfterwards": 0, + "effectiveDpiSeenByAwareArm": 96, + "scaleActuallyApplied": false, + "awareArm": { + "mode": "aware", + "processId": 8764, + "awarenessAtStartup": "PROCESS_DPI_UNAWARE", + "setProcessDpiAwarenessContextPerMonitorV2": "succeeded", + "awarenessEffective": "PROCESS_PER_MONITOR_DPI_AWARE", + "monitorEffectiveDpiX": 96, + "monitorEffectiveDpiY": 96, + "logPixels": 96, + "screenMetricsCx": 1639, + "screenMetricsCy": 732, + "getWindowRect": { + "Left": 476, + "Top": 100, + "Right": 1244, + "Bottom": 625 + }, + "uiaBounds_window": { + "className": "Notepad", + "Left": 476, + "Top": 100, + "Width": 768, + "Height": 525 + }, + "uiaBounds_editChild": { + "className": "Edit", + "Left": 484, + "Top": 151, + "Width": 752, + "Height": 443 + } + }, + "unawareArm": { + "mode": "unaware", + "processId": 304, + "awarenessAtStartup": "PROCESS_DPI_UNAWARE", + "setProcessDpiAwarenessContextPerMonitorV2": "not attempted (unaware arm, forced with __COMPAT_LAYER=DPIUNAWARE)", + "awarenessEffective": "PROCESS_DPI_UNAWARE", + "monitorEffectiveDpiX": 96, + "monitorEffectiveDpiY": 96, + "logPixels": 96, + "screenMetricsCx": 1639, + "screenMetricsCy": 732, + "getWindowRect": { + "Left": 476, + "Top": 100, + "Right": 1244, + "Bottom": 625 + }, + "uiaBounds_window": { + "className": "Notepad", + "Left": 476, + "Top": 100, + "Width": 768, + "Height": 525 + }, + "uiaBounds_editChild": { + "className": "Edit", + "Left": 484, + "Top": 151, + "Width": 752, + "Height": 443 + } + }, + "windowBoundsDelta": { + "measurable": true, + "deltaLeft": 0, + "deltaTop": 0, + "deltaWidth": 0, + "deltaHeight": 0 + }, + "editChildBoundsDelta": { + "measurable": true, + "deltaLeft": 0, + "deltaTop": 0, + "deltaWidth": 0, + "deltaHeight": 0 + } + }, + { + "pass": "plus-one-step", + "requested": "one scale step above recommended (125%)", + "requestedScaleRel": 1, + "displayConfigSetResult": 0, + "scaleRelReportedAfterwards": 1, + "registryDpiValueAfterwards": 1, + "effectiveDpiSeenByAwareArm": 96, + "scaleActuallyApplied": false, + "awareArm": { + "mode": "aware", + "processId": 8412, + "awarenessAtStartup": "PROCESS_DPI_UNAWARE", + "setProcessDpiAwarenessContextPerMonitorV2": "succeeded", + "awarenessEffective": "PROCESS_PER_MONITOR_DPI_AWARE", + "monitorEffectiveDpiX": 96, + "monitorEffectiveDpiY": 96, + "logPixels": 96, + "screenMetricsCx": 1639, + "screenMetricsCy": 732, + "getWindowRect": { + "Left": 476, + "Top": 100, + "Right": 1244, + "Bottom": 625 + }, + "uiaBounds_window": { + "className": "Notepad", + "Left": 476, + "Top": 100, + "Width": 768, + "Height": 525 + }, + "uiaBounds_editChild": { + "className": "Edit", + "Left": 484, + "Top": 151, + "Width": 752, + "Height": 443 + } + }, + "unawareArm": { + "mode": "unaware", + "processId": 5060, + "awarenessAtStartup": "PROCESS_DPI_UNAWARE", + "setProcessDpiAwarenessContextPerMonitorV2": "not attempted (unaware arm, forced with __COMPAT_LAYER=DPIUNAWARE)", + "awarenessEffective": "PROCESS_DPI_UNAWARE", + "monitorEffectiveDpiX": 96, + "monitorEffectiveDpiY": 96, + "logPixels": 96, + "screenMetricsCx": 1639, + "screenMetricsCy": 732, + "getWindowRect": { + "Left": 476, + "Top": 100, + "Right": 1244, + "Bottom": 625 + }, + "uiaBounds_window": { + "className": "Notepad", + "Left": 476, + "Top": 100, + "Width": 768, + "Height": 525 + }, + "uiaBounds_editChild": { + "className": "Edit", + "Left": 484, + "Top": 151, + "Width": 752, + "Height": 443 + } + }, + "windowBoundsDelta": { + "measurable": true, + "deltaLeft": 0, + "deltaTop": 0, + "deltaWidth": 0, + "deltaHeight": 0 + }, + "editChildBoundsDelta": { + "measurable": true, + "deltaLeft": 0, + "deltaTop": 0, + "deltaWidth": 0, + "deltaHeight": 0 + } + } + ], + "teardown": { + "originalScaleRel": 0, + "displayConfigSetResult": 0, + "scaleRelReadBackAfterwards": 0, + "registryDpiValueAfterwards": 0, + "restored": true + }, + "deferred": { + "row": "multi-monitor and mixed-DPI bounds behavior, and any non-zero aware-vs-unaware delta", + "reason": "single display, and that display reports no EDID so Windows offers exactly one scale step (min=cur=max); the 125% request is accepted by DisplayConfigSetDeviceInfo and persisted to the registry but never takes effect", + "closesAt": "sub-phase 2.4 (owns list_displays and per-monitor scale_factor) on a runner with a scalable display", + "neverLeaves": "Phase 2" + } +} \ No newline at end of file diff --git a/probes/windows/captures/10-session-dpi/session-dpi.json.normalized b/probes/windows/captures/10-session-dpi/session-dpi.json.normalized new file mode 100644 index 0000000..ac02188 --- /dev/null +++ b/probes/windows/captures/10-session-dpi/session-dpi.json.normalized @@ -0,0 +1,224 @@ +{ + "question": "what bounds delta does DPI awareness produce between two processes reading the same element, and what scale can this environment actually apply", + "stack": "managed UIA for BoundingRectangle; raw Win32 for awareness, DisplayConfig and metrics", + "scope": "api-contract for the awareness APIs; app/provider and environment-specific for the achievable scale", + "session": { + "sessionId": 1, + "activeConsoleSessionId": 1, + "isRemoteSession": false, + "windowStation": "Console", + "note": "RDP session-transition behavior is not measurable here (physical console session); it closes at sub-phase 2.1 runner registration" + }, + "displays": [ + { + "primary": true, + "Left": 0, + "Top": 0, + "Width": 1640, + "Height": 736 + } + ], + "displayScaleCapability": { + "displayConfigGetResult": 0, + "scaleRelMin": 0, + "scaleRelCurrent": 0, + "scaleRelMax": 0, + "monitorRegistryKeys": [ + "NOEDID_15AD_0405_00000000_000F0000_0^20ED182961F2CFDB3A2D28C95A99744F" + ], + "stepsAvailableAboveRecommended": 0 + }, + "method": "two sibling powershell children read the same probe-launched notepad window and its Edit child; the unaware arm is forced with __COMPAT_LAYER=DPIUNAWARE, the aware arm calls SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)", + "normalizationNote": "raw rectangles are bucketed to 8px by the KTD9 normalizer; delta fields are named deltaLeft/deltaTop/deltaWidth/deltaHeight, which the bucketing regex does not match, so the measured delta survives the normalized twin verbatim", + "passes": [ + { + "pass": "recommended", + "requested": "display recommended scale (100%)", + "requestedScaleRel": 0, + "displayConfigSetResult": null, + "scaleRelReportedAfterwards": 0, + "registryDpiValueAfterwards": 0, + "effectiveDpiSeenByAwareArm": 96, + "scaleActuallyApplied": false, + "awareArm": { + "mode": "aware", + "processId": , + "awarenessAtStartup": "PROCESS_DPI_UNAWARE", + "setProcessDpiAwarenessContextPerMonitorV2": "succeeded", + "awarenessEffective": "PROCESS_PER_MONITOR_DPI_AWARE", + "monitorEffectiveDpiX": 96, + "monitorEffectiveDpiY": 96, + "logPixels": 96, + "screenMetricsCx": 1639, + "screenMetricsCy": 732, + "getWindowRect": { + "Left": 480, + "Top": 96, + "Right": 1248, + "Bottom": 624 + }, + "uiaBounds_window": { + "className": "Notepad", + "Left": 480, + "Top": 96, + "Width": 768, + "Height": 528 + }, + "uiaBounds_editChild": { + "className": "Edit", + "Left": 480, + "Top": 152, + "Width": 752, + "Height": 440 + } + }, + "unawareArm": { + "mode": "unaware", + "processId": , + "awarenessAtStartup": "PROCESS_DPI_UNAWARE", + "setProcessDpiAwarenessContextPerMonitorV2": "not attempted (unaware arm, forced with __COMPAT_LAYER=DPIUNAWARE)", + "awarenessEffective": "PROCESS_DPI_UNAWARE", + "monitorEffectiveDpiX": 96, + "monitorEffectiveDpiY": 96, + "logPixels": 96, + "screenMetricsCx": 1639, + "screenMetricsCy": 732, + "getWindowRect": { + "Left": 480, + "Top": 96, + "Right": 1248, + "Bottom": 624 + }, + "uiaBounds_window": { + "className": "Notepad", + "Left": 480, + "Top": 96, + "Width": 768, + "Height": 528 + }, + "uiaBounds_editChild": { + "className": "Edit", + "Left": 480, + "Top": 152, + "Width": 752, + "Height": 440 + } + }, + "windowBoundsDelta": { + "measurable": true, + "deltaLeft": 0, + "deltaTop": 0, + "deltaWidth": 0, + "deltaHeight": 0 + }, + "editChildBoundsDelta": { + "measurable": true, + "deltaLeft": 0, + "deltaTop": 0, + "deltaWidth": 0, + "deltaHeight": 0 + } + }, + { + "pass": "plus-one-step", + "requested": "one scale step above recommended (125%)", + "requestedScaleRel": 1, + "displayConfigSetResult": 0, + "scaleRelReportedAfterwards": 1, + "registryDpiValueAfterwards": 1, + "effectiveDpiSeenByAwareArm": 96, + "scaleActuallyApplied": false, + "awareArm": { + "mode": "aware", + "processId": , + "awarenessAtStartup": "PROCESS_DPI_UNAWARE", + "setProcessDpiAwarenessContextPerMonitorV2": "succeeded", + "awarenessEffective": "PROCESS_PER_MONITOR_DPI_AWARE", + "monitorEffectiveDpiX": 96, + "monitorEffectiveDpiY": 96, + "logPixels": 96, + "screenMetricsCx": 1639, + "screenMetricsCy": 732, + "getWindowRect": { + "Left": 480, + "Top": 96, + "Right": 1248, + "Bottom": 624 + }, + "uiaBounds_window": { + "className": "Notepad", + "Left": 480, + "Top": 96, + "Width": 768, + "Height": 528 + }, + "uiaBounds_editChild": { + "className": "Edit", + "Left": 480, + "Top": 152, + "Width": 752, + "Height": 440 + } + }, + "unawareArm": { + "mode": "unaware", + "processId": , + "awarenessAtStartup": "PROCESS_DPI_UNAWARE", + "setProcessDpiAwarenessContextPerMonitorV2": "not attempted (unaware arm, forced with __COMPAT_LAYER=DPIUNAWARE)", + "awarenessEffective": "PROCESS_DPI_UNAWARE", + "monitorEffectiveDpiX": 96, + "monitorEffectiveDpiY": 96, + "logPixels": 96, + "screenMetricsCx": 1639, + "screenMetricsCy": 732, + "getWindowRect": { + "Left": 480, + "Top": 96, + "Right": 1248, + "Bottom": 624 + }, + "uiaBounds_window": { + "className": "Notepad", + "Left": 480, + "Top": 96, + "Width": 768, + "Height": 528 + }, + "uiaBounds_editChild": { + "className": "Edit", + "Left": 480, + "Top": 152, + "Width": 752, + "Height": 440 + } + }, + "windowBoundsDelta": { + "measurable": true, + "deltaLeft": 0, + "deltaTop": 0, + "deltaWidth": 0, + "deltaHeight": 0 + }, + "editChildBoundsDelta": { + "measurable": true, + "deltaLeft": 0, + "deltaTop": 0, + "deltaWidth": 0, + "deltaHeight": 0 + } + } + ], + "teardown": { + "originalScaleRel": 0, + "displayConfigSetResult": 0, + "scaleRelReadBackAfterwards": 0, + "registryDpiValueAfterwards": 0, + "restored": true + }, + "deferred": { + "row": "multi-monitor and mixed-DPI bounds behavior, and any non-zero aware-vs-unaware delta", + "reason": "single display, and that display reports no EDID so Windows offers exactly one scale step (min=cur=max); the 125% request is accepted by DisplayConfigSetDeviceInfo and persisted to the registry but never takes effect", + "closesAt": "sub-phase 2.4 (owns list_displays and per-monitor scale_factor) on a runner with a scalable display", + "neverLeaves": "Phase 2" + } +} \ No newline at end of file diff --git a/probes/windows/captures/11-electron-activation/electron-activation.json b/probes/windows/captures/11-electron-activation/electron-activation.json new file mode 100644 index 0000000..bd70644 --- /dev/null +++ b/probes/windows/captures/11-electron-activation/electron-activation.json @@ -0,0 +1,164 @@ +{ + "question": "does Chromium 138+ expose a UIA tree to any UIA client with no flag, and does --force-renderer-accessibility still change anything", + "gradedAgainst": "the committed phases.md line that Chromium 138+ exposes a UIA tree to any UIA client with no flag (P2-O15 / 2.4)", + "stack": "both: managed System.Windows.Automation and the U7 UIA3 COM shim (KTD1 - the COM rows are the authoritative ones)", + "scope": "app/provider - specific to this Obsidian/Electron/Chromium build; the activation mechanism is Chromium behavior, not a Windows API contract", + "versions": { + "method": "user-agent string scanned out of the shipped Obsidian.exe on this installation", + "obsidianFileVersion": "1.12.7.0", + "chromiumVersion": "142.0.7444.265", + "electronVersion": "39.8.3", + "established": true, + "rejectedMethods": [ + "ELECTRON_RUN_AS_NODE=1 Obsidian.exe -p process.versions produced no output on this VM", + "framework DLL file versions carry component versions, not the Chromium version" + ] + }, + "obsidianPreexistingAtProbeStart": false, + "method": "one fresh Obsidian instance per (stack x flag) cell, because the first UIA client to touch a renderer activates accessibility for that process", + "placementHazards": [ + "U3: an earlier revision that left other windows on top of Obsidian held it at its first-contact node count through a full 8s settle and a 16s instrumented hold, three runs running. Minimizing the covering windows fixed it. The mechanism was NOT isolated (Chromium native-window occlusion tracking is the obvious candidate, unproven). Carried here as a probe-placement hazard, explicitly not a product claim.", + "U4: matching a bare Chrome_WidgetWin_1 window found an unrelated window and recorded 9 nodes / 0% coverage as if it were the Electron answer. This probe only accepts a window whose owning pid is one of the launched Obsidian pids, and reads the hosting pid back off the window." + ], + "countStabilityNote": "settled node counts are content-dependent (U7 measured RawView 172 on a bare walker run, U3 measured 119 through its probe). The stable fact is the growth ratio across the settle, not the absolute count.", + "cells": [ + { + "flagArm": "no-flag", + "stack": "managed", + "authoritative": false, + "windowClassName": "Chrome_WidgetWin_1", + "windowHandle": 5309714, + "windowHost": { + "processId": 9204 + }, + "latencyObsidianProcessCountAtWindowDiscovery": 4, + "foregroundOwner": { + "processId": 980 + }, + "settleMs": 8000, + "firstContact": { + "latencyFirstContactRawViewNodes": 13, + "latencyFirstContactRawViewRefableNodes": 13, + "latencyFirstContactControlViewNodes": 130, + "latencyFirstContactControlViewRefableNodes": 130 + }, + "afterSettle": { + "settledRawViewNodes": 172, + "settledRawViewRefableNodes": 172, + "settledControlViewNodes": 133, + "settledControlViewRefableNodes": 133 + }, + "latencyRawViewGrowthRatio": 13.23, + "activatedWithoutFlag": true + }, + { + "flagArm": "no-flag", + "stack": "uia3-com", + "authoritative": true, + "windowClassName": "Chrome_WidgetWin_1", + "windowHandle": 6422788, + "windowHost": { + "processId": 9000 + }, + "latencyObsidianProcessCountAtWindowDiscovery": 4, + "foregroundOwner": { + "processId": 980 + }, + "settleMs": 8000, + "firstContact": { + "latencyFirstContactRawViewNodes": 13, + "latencyFirstContactControlViewNodes": 9, + "latencyFirstContactContentViewNodes": 9 + }, + "afterSettle": { + "settledRawViewNodes": 172, + "settledControlViewNodes": 133, + "settledContentViewNodes": 133, + "settledRefableNodesWithAnyPattern": 171, + "settledRefableNodesExcludingLegacy": 171 + }, + "censusNote": "census ran in a second shim process against the already-activated instance, so its counts are settled counts; per-element rows are deliberately not captured (R11: Obsidian note titles are content)", + "latencyRawViewGrowthRatio": 13.23, + "activatedWithoutFlag": true + }, + { + "flagArm": "force-renderer-accessibility", + "stack": "managed", + "authoritative": false, + "windowClassName": "Chrome_WidgetWin_1", + "windowHandle": 11993994, + "windowHost": { + "processId": 7840 + }, + "latencyObsidianProcessCountAtWindowDiscovery": 4, + "foregroundOwner": { + "processId": 980 + }, + "settleMs": 8000, + "firstContact": { + "latencyFirstContactRawViewNodes": 164, + "latencyFirstContactRawViewRefableNodes": 163, + "latencyFirstContactControlViewNodes": 133, + "latencyFirstContactControlViewRefableNodes": 133 + }, + "afterSettle": { + "settledRawViewNodes": 172, + "settledRawViewRefableNodes": 172, + "settledControlViewNodes": 133, + "settledControlViewRefableNodes": 133 + }, + "latencyRawViewGrowthRatio": 1.05, + "activatedWithoutFlag": false + }, + { + "flagArm": "force-renderer-accessibility", + "stack": "uia3-com", + "authoritative": true, + "windowClassName": "Chrome_WidgetWin_1", + "windowHandle": 5570818, + "windowHost": { + "processId": 6108 + }, + "latencyObsidianProcessCountAtWindowDiscovery": 4, + "foregroundOwner": { + "processId": 980 + }, + "settleMs": 8000, + "firstContact": { + "latencyFirstContactRawViewNodes": 141, + "latencyFirstContactControlViewNodes": 127, + "latencyFirstContactContentViewNodes": 131 + }, + "afterSettle": { + "settledRawViewNodes": 172, + "settledControlViewNodes": 133, + "settledContentViewNodes": 133, + "settledRefableNodesWithAnyPattern": 172, + "settledRefableNodesExcludingLegacy": 172 + }, + "censusNote": "census ran in a second shim process against the already-activated instance, so its counts are settled counts; per-element rows are deliberately not captured (R11: Obsidian note titles are content)", + "latencyRawViewGrowthRatio": 1.22, + "activatedWithoutFlag": false + } + ], + "findings": { + "managedActivatesWithoutFlag": true, + "comActivatesWithoutFlag": true, + "clientStackDivergence": false, + "latencyComRawViewNoFlagFirstContact": 13, + "latencyComRawViewWithFlagFirstContact": 141, + "latencyManagedRawViewNoFlagFirstContact": 13, + "latencyManagedRawViewWithFlagFirstContact": 164, + "settledComRawViewNoFlag": 172, + "settledComRawViewWithFlag": 172, + "settledManagedRawViewNoFlag": 172, + "settledManagedRawViewWithFlag": 172, + "settledCountsMatchAcrossFlagArms": true, + "firstContactCountsAreRunVarying": "first-contact counts are a race against Chromium async tree construction and are named latency* so the KTD9 normalizer canonicalizes them; the raw samples stay in this capture, the settled counts are the stable evidence", + "flagVerdict": "partially useful - redundant for eventual tree exposure: on Chromium 142.0.7444.265 both client stacks reach the same settled RawView count with no flag, so the phases.md claim holds for exposure. It is not redundant for first-contact readiness: without the flag first contact is deterministically the small pre-activation shell on every run and both stacks, while with the flag first contact is a race against the async tree build and was observed anywhere from a fraction of the settled count to nearly all of it. Neither arm removes the need to settle before trusting a first snapshot.", + "supersededPreFinding": "the plan pre-finding (8 managed descendants, stable at 2/5/10s, candidate CONTRADICTS) is superseded: it was measured through an occluded window with a managed client, and neither the occlusion nor the settle window was controlled" + }, + "teardown": { + "allObsidianProcessesTerminated": true + } +} \ No newline at end of file diff --git a/probes/windows/captures/11-electron-activation/electron-activation.json.normalized b/probes/windows/captures/11-electron-activation/electron-activation.json.normalized new file mode 100644 index 0000000..a9235db --- /dev/null +++ b/probes/windows/captures/11-electron-activation/electron-activation.json.normalized @@ -0,0 +1,164 @@ +{ + "question": "does Chromium 138+ expose a UIA tree to any UIA client with no flag, and does --force-renderer-accessibility still change anything", + "gradedAgainst": "the committed phases.md line that Chromium 138+ exposes a UIA tree to any UIA client with no flag (P2-O15 / 2.4)", + "stack": "both: managed System.Windows.Automation and the U7 UIA3 COM shim (KTD1 - the COM rows are the authoritative ones)", + "scope": "app/provider - specific to this Obsidian/Electron/Chromium build; the activation mechanism is Chromium behavior, not a Windows API contract", + "versions": { + "method": "user-agent string scanned out of the shipped Obsidian.exe on this installation", + "obsidianFileVersion": "1.12.7.0", + "chromiumVersion": "142.0.7444.265", + "electronVersion": "39.8.3", + "established": true, + "rejectedMethods": [ + "ELECTRON_RUN_AS_NODE=1 Obsidian.exe -p process.versions produced no output on this VM", + "framework DLL file versions carry component versions, not the Chromium version" + ] + }, + "obsidianPreexistingAtProbeStart": false, + "method": "one fresh Obsidian instance per (stack x flag) cell, because the first UIA client to touch a renderer activates accessibility for that process", + "placementHazards": [ + "U3: an earlier revision that left other windows on top of Obsidian held it at its first-contact node count through a full 8s settle and a 16s instrumented hold, three runs running. Minimizing the covering windows fixed it. The mechanism was NOT isolated (Chromium native-window occlusion tracking is the obvious candidate, unproven). Carried here as a probe-placement hazard, explicitly not a product claim.", + "U4: matching a bare Chrome_WidgetWin_1 window found an unrelated window and recorded 9 nodes / 0% coverage as if it were the Electron answer. This probe only accepts a window whose owning pid is one of the launched Obsidian pids, and reads the hosting pid back off the window." + ], + "countStabilityNote": "settled node counts are content-dependent (U7 measured RawView 172 on a bare walker run, U3 measured 119 through its probe). The stable fact is the growth ratio across the settle, not the absolute count.", + "cells": [ + { + "flagArm": "no-flag", + "stack": "managed", + "authoritative": false, + "windowClassName": "Chrome_WidgetWin_1", + "windowHandle": , + "windowHost": { + "processId": + }, + "latencyObsidianProcessCountAtWindowDiscovery": , + "foregroundOwner": { + "processId": + }, + "settleMs": 8000, + "firstContact": { + "latencyFirstContactRawViewNodes": , + "latencyFirstContactRawViewRefableNodes": , + "latencyFirstContactControlViewNodes": , + "latencyFirstContactControlViewRefableNodes": + }, + "afterSettle": { + "settledRawViewNodes": 172, + "settledRawViewRefableNodes": 172, + "settledControlViewNodes": 133, + "settledControlViewRefableNodes": 133 + }, + "latencyRawViewGrowthRatio": , + "activatedWithoutFlag": true + }, + { + "flagArm": "no-flag", + "stack": "uia3-com", + "authoritative": true, + "windowClassName": "Chrome_WidgetWin_1", + "windowHandle": , + "windowHost": { + "processId": + }, + "latencyObsidianProcessCountAtWindowDiscovery": , + "foregroundOwner": { + "processId": + }, + "settleMs": 8000, + "firstContact": { + "latencyFirstContactRawViewNodes": , + "latencyFirstContactControlViewNodes": , + "latencyFirstContactContentViewNodes": + }, + "afterSettle": { + "settledRawViewNodes": 172, + "settledControlViewNodes": 133, + "settledContentViewNodes": 133, + "settledRefableNodesWithAnyPattern": 171, + "settledRefableNodesExcludingLegacy": 171 + }, + "censusNote": "census ran in a second shim process against the already-activated instance, so its counts are settled counts; per-element rows are deliberately not captured (R11: Obsidian note titles are content)", + "latencyRawViewGrowthRatio": , + "activatedWithoutFlag": true + }, + { + "flagArm": "force-renderer-accessibility", + "stack": "managed", + "authoritative": false, + "windowClassName": "Chrome_WidgetWin_1", + "windowHandle": , + "windowHost": { + "processId": + }, + "latencyObsidianProcessCountAtWindowDiscovery": , + "foregroundOwner": { + "processId": + }, + "settleMs": 8000, + "firstContact": { + "latencyFirstContactRawViewNodes": , + "latencyFirstContactRawViewRefableNodes": , + "latencyFirstContactControlViewNodes": , + "latencyFirstContactControlViewRefableNodes": + }, + "afterSettle": { + "settledRawViewNodes": 172, + "settledRawViewRefableNodes": 172, + "settledControlViewNodes": 133, + "settledControlViewRefableNodes": 133 + }, + "latencyRawViewGrowthRatio": , + "activatedWithoutFlag": false + }, + { + "flagArm": "force-renderer-accessibility", + "stack": "uia3-com", + "authoritative": true, + "windowClassName": "Chrome_WidgetWin_1", + "windowHandle": , + "windowHost": { + "processId": + }, + "latencyObsidianProcessCountAtWindowDiscovery": , + "foregroundOwner": { + "processId": + }, + "settleMs": 8000, + "firstContact": { + "latencyFirstContactRawViewNodes": , + "latencyFirstContactControlViewNodes": , + "latencyFirstContactContentViewNodes": + }, + "afterSettle": { + "settledRawViewNodes": 172, + "settledControlViewNodes": 133, + "settledContentViewNodes": 133, + "settledRefableNodesWithAnyPattern": 172, + "settledRefableNodesExcludingLegacy": 172 + }, + "censusNote": "census ran in a second shim process against the already-activated instance, so its counts are settled counts; per-element rows are deliberately not captured (R11: Obsidian note titles are content)", + "latencyRawViewGrowthRatio": , + "activatedWithoutFlag": false + } + ], + "findings": { + "managedActivatesWithoutFlag": true, + "comActivatesWithoutFlag": true, + "clientStackDivergence": false, + "latencyComRawViewNoFlagFirstContact": , + "latencyComRawViewWithFlagFirstContact": , + "latencyManagedRawViewNoFlagFirstContact": , + "latencyManagedRawViewWithFlagFirstContact": , + "settledComRawViewNoFlag": 172, + "settledComRawViewWithFlag": 172, + "settledManagedRawViewNoFlag": 172, + "settledManagedRawViewWithFlag": 172, + "settledCountsMatchAcrossFlagArms": true, + "firstContactCountsAreRunVarying": "first-contact counts are a race against Chromium async tree construction and are named latency* so the KTD9 normalizer canonicalizes them; the raw samples stay in this capture, the settled counts are the stable evidence", + "flagVerdict": "partially useful - redundant for eventual tree exposure: on Chromium 142.0.7444.265 both client stacks reach the same settled RawView count with no flag, so the phases.md claim holds for exposure. It is not redundant for first-contact readiness: without the flag first contact is deterministically the small pre-activation shell on every run and both stacks, while with the flag first contact is a race against the async tree build and was observed anywhere from a fraction of the settled count to nearly all of it. Neither arm removes the need to settle before trusting a first snapshot.", + "supersededPreFinding": "the plan pre-finding (8 managed descendants, stable at 2/5/10s, candidate CONTRADICTS) is superseded: it was measured through an occluded window with a managed client, and neither the occlusion nor the settle window was controlled" + }, + "teardown": { + "allObsidianProcessesTerminated": true + } +} \ No newline at end of file