From f587eeecc860cce026bd84f1c840f6231c45a600 Mon Sep 17 00:00:00 2001 From: Elliot Michlin <31219104+theelliotm@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:59:50 -0700 Subject: [PATCH 1/9] test(process-container): realign UI policy and captureDenials suites The nightly process-container jobs fail ~114 assertions that encode behavior two merged PRs changed, not product regressions. PR #1368 established that a permissive UI knob only omits the corresponding JOB_OBJECT_UILIMIT bit; it never grants the operation. That is mechanism-general, but only the desktopSystemControl section was updated, so the UI policy matrix still asserted an undocumented "grant" floor and failed wherever the host denies a capability independently. Replace that assertion with a measurement. Measure-UiGrantable runs a fully permissive baseline to learn which capabilities are reachable on this host, and the per-knob cases skip only the allow direction for capabilities that are not. The block direction stays strict everywhere: an operation that succeeds under a deny expectation is still a failure. A baseline that does not report on every tag records a failure and gates nothing, so coverage is never dropped silently. Phase 4b now parses INCONCLUSIVE instead of collapsing it to . PR #1290 removed the captureDenials fallback: where no capture provider is available the request fails before the sandbox is created. Detect both providers via wxc-exec --probe and assert that documented outcome instead of the removed one. The guarded provider resolves plm.exe next to the loaded module, so probe it from the debug binary the suites actually run. Capture assertions that need a live session now skip when no provider exists. Propagate the #1368 caveat to the clipboard and systemSettings sections, and note that the os-version matrix tracks flag availability rather than granted behavior. Two failure clusters are deliberately left out of scope and are now reported honestly rather than as containment defects: the hostLoopback rejection-detection gap, and the clipboard read probe seeding, which cannot work from the host because the clipboard is per-window-station. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/process-container/UIPolicy_Schema.md | 10 +- docs/process-container/os-version-support.md | 4 + .../lib/WinProcessContainer.Common.ps1 | 28 ++++- ..._processcontainer_capture_denials_test.ps1 | 16 +++ .../run_processcontainer_privilege_test.ps1 | 34 ++++-- ...n_processcontainer_ui_mitigations_test.ps1 | 13 ++- ...processcontainer_ui_policy_matrix_test.ps1 | 103 +++++++++++++----- 7 files changed, 166 insertions(+), 42 deletions(-) diff --git a/docs/process-container/UIPolicy_Schema.md b/docs/process-container/UIPolicy_Schema.md index dc7f16e0a..69a4a4396 100644 --- a/docs/process-container/UIPolicy_Schema.md +++ b/docs/process-container/UIPolicy_Schema.md @@ -97,6 +97,8 @@ The `"ui"` section is a sibling of `"processContainer"`, `"filesystem"`, `"netwo | `"write"` | `UILIMIT_READCLIPBOARD` | ❌ Blocked | ✅ Allowed | Process can copy but not paste in | | `"none"` | `UILIMIT_READCLIPBOARD` + `UILIMIT_WRITECLIPBOARD` | ❌ Blocked | ❌ Blocked | Complete clipboard isolation | +> ✅ means MXC does not set the corresponding UI limit. It does not explicitly enable clipboard access — other parts of the process-container security environment may still deny it. + --- ### `isolation` @@ -165,6 +167,8 @@ The `"ui"` section is a sibling of `"processContainer"`, `"filesystem"`, `"netwo | `"display"` | `UILIMIT_SYSTEMPARAMETERS` | ❌ Blocked | ✅ Allowed | Process can change display but not UI params | | `"none"` | `UILIMIT_SYSTEMPARAMETERS` + `UILIMIT_DISPLAYSETTINGS` | ❌ Blocked | ❌ Blocked | No system settings changes | +> ✅ means MXC does not set the corresponding UI limit. It does not explicitly enable the setting change — other parts of the process-container security environment may still deny it. + --- ### `ime` @@ -262,7 +266,7 @@ All defaults apply — maximum lockdown. ### Example 3: Selective permissions — GUI with clipboard access -Process has full GUI access and can interact with other windows on the desktop. Full clipboard access is granted, but all other capabilities remain locked down. +Process keeps GUI access and can interact with other windows on the desktop. MXC sets no clipboard limit, but all other capabilities remain locked down. ```json "ui": { @@ -284,7 +288,7 @@ This section is an implementation reference for runner developers. The JSON fiel | `clipboard` | `"none"` | `JOB_OBJECT_UILIMIT_READCLIPBOARD` + `JOB_OBJECT_UILIMIT_WRITECLIPBOARD` | | `clipboard` | `"read"` | `JOB_OBJECT_UILIMIT_WRITECLIPBOARD` | | `clipboard` | `"write"` | `JOB_OBJECT_UILIMIT_READCLIPBOARD` | -| `clipboard` | `"all"` | *(no flags)* | +| `clipboard` | `"all"` | *(no flags; does not explicitly enable clipboard access)* | | `isolation` | `"container"` | `JOB_OBJECT_UILIMIT_HANDLES` + `JOB_OBJECT_UILIMIT_GLOBALATOMS` | | `isolation` | `"handles"` | `JOB_OBJECT_UILIMIT_HANDLES` | | `isolation` | `"atoms"` | `JOB_OBJECT_UILIMIT_GLOBALATOMS` | @@ -294,7 +298,7 @@ This section is an implementation reference for runner developers. The JSON fiel | `systemSettings` | `"none"` | `JOB_OBJECT_UILIMIT_SYSTEMPARAMETERS` + `JOB_OBJECT_UILIMIT_DISPLAYSETTINGS` | | `systemSettings` | `"parameters"` | `JOB_OBJECT_UILIMIT_DISPLAYSETTINGS` | | `systemSettings` | `"display"` | `JOB_OBJECT_UILIMIT_SYSTEMPARAMETERS` | -| `systemSettings` | `"all"` | *(no flags)* | +| `systemSettings` | `"all"` | *(no flags; does not explicitly enable the setting changes)* | | `ime` | `false` | `JOB_OBJECT_UILIMIT_IME` | | `injection` | `false` | `JOB_OBJECT_UILIMIT_INJECTION` | diff --git a/docs/process-container/os-version-support.md b/docs/process-container/os-version-support.md index 8c7587a73..b31e83654 100644 --- a/docs/process-container/os-version-support.md +++ b/docs/process-container/os-version-support.md @@ -175,6 +175,10 @@ would reject; `wxc-exec --probe` reports what a host can enforce. | `injection` — INJECTION (`0x200`, ≥ 26100) | ❌ | ✅ | ✅ | ✅ | | `disable` — `disallowWin32kSystemCalls` mitigation | ✅ | ✅ | ✅ | ✅ | +✅ here means the host can set the flag, not that the permissive value grants +the operation. Omitting a limit never enables anything; see +[UI policy schema](UIPolicy_Schema.md). + The single UI differentiator for 23H2 is **`injection`** (`JOB_OBJECT_UILIMIT_INJECTION`), which the kernel accepts only on build 26100 and later (`MIN_BUILD_FOR_INJECTION_LIMIT`) and is therefore unavailable on diff --git a/tests/scripts/lib/WinProcessContainer.Common.ps1 b/tests/scripts/lib/WinProcessContainer.Common.ps1 index 5967c70f5..469aa9732 100644 --- a/tests/scripts/lib/WinProcessContainer.Common.ps1 +++ b/tests/scripts/lib/WinProcessContainer.Common.ps1 @@ -357,6 +357,21 @@ function Get-HostCapabilities { $p.probes.uiCapabilities.PSObject.Properties['canBlockInputInjection']) { $canInject = [bool]$p.probes.uiCapabilities.canBlockInputInjection } + # The two independent captureDenials providers. Probed from the debug + # binary because guarded capture resolves plm.exe next to the loaded + # module, so the release sidecar's trust result does not answer for the + # binary these tests actually run. + $pd = Invoke-Probe -Wxc $WxcDebug -Phase 'P0' -Name 'host-capabilities (capture providers)' + $nativeCapture = $false + $guardedCapture = $false + if ($pd -and $pd.PSObject.Properties['probes']) { + if ($pd.probes.PSObject.Properties['nativeCaptureAvailable']) { + $nativeCapture = [bool]$pd.probes.nativeCaptureAvailable + } + if ($pd.probes.PSObject.Properties['guardedCaptureAvailable']) { + $guardedCapture = [bool]$pd.probes.guardedCaptureAvailable + } + } return [pscustomobject]@{ BaselineTier = $tier BaseContainerUsable = ($tier -eq 'base-container') @@ -380,6 +395,14 @@ function Get-HostCapabilities { # EnumeratePathsUnsupported). So it is available only where the host # both selects base-container and advertises the capability. SupportsEnumeratePaths = (($tier -eq 'base-container') -and $enumBit) + # Native PSEC/V2 capture is reachable only on base-container; every + # AppContainer tier needs the guarded WPR fallback. Where neither + # provider is present a captureDenials request fails before MXC + # creates the sandbox (docs/schema.md), so the behavioral assertions + # that need a capture session to exist have nothing to observe. + NativeCaptureAvailable = $nativeCapture + GuardedCaptureAvailable = $guardedCapture + CaptureDenialsUsable = ((($tier -eq 'base-container') -and $nativeCapture) -or $guardedCapture) } } @@ -1228,10 +1251,11 @@ function Initialize-WpcContext { } if ($Fresh) { - Write-Host ("Host capabilities: expectedTier={0} baseContainerUsable={1} apiPresent={2} bfscfgPresent={3} bfsCompiledIn={4} supportsDeniedPaths={5} supportsEnumeratePaths={6}" -f ` + Write-Host ("Host capabilities: expectedTier={0} baseContainerUsable={1} apiPresent={2} bfscfgPresent={3} bfsCompiledIn={4} supportsDeniedPaths={5} supportsEnumeratePaths={6} captureDenialsUsable={7} (native={8} guarded={9})" -f ` $Script:Caps.BaselineTier, $Script:Caps.BaseContainerUsable, $Script:Caps.BaseContainerApiPresent, ` $Script:Caps.BfscfgPresent, $Script:Caps.BfsCompiledIn, $Script:Caps.SupportsDeniedPaths, ` - $Script:Caps.SupportsEnumeratePaths) -ForegroundColor Cyan + $Script:Caps.SupportsEnumeratePaths, $Script:Caps.CaptureDenialsUsable, ` + $Script:Caps.NativeCaptureAvailable, $Script:Caps.GuardedCaptureAvailable) -ForegroundColor Cyan } } diff --git a/tests/scripts/run_processcontainer_capture_denials_test.ps1 b/tests/scripts/run_processcontainer_capture_denials_test.ps1 index dfea025dd..60195d9b7 100644 --- a/tests/scripts/run_processcontainer_capture_denials_test.ps1 +++ b/tests/scripts/run_processcontainer_capture_denials_test.ps1 @@ -153,6 +153,11 @@ function Phase-CaptureDenialsModes { # warning". The warning is the user-visible half of that contract, and a # silent relaxation of deny-by-default is exactly the regression worth # catching. Asserted only when the run got far enough to emit it. + if (-not $Script:Caps.CaptureDenialsUsable) { + Record-Result -Phase 'P14b' -Name 'captureDenials mode=allow emits a security warning' -Status 'skip' ` + -Detail 'no capture provider on this host, so the request fails before a capture session exists to warn about' + return + } $cfg = New-Config -Name 'cd-allow-warning' -CommandLine $cmd -ReadWrite @($rw) ` -CaptureDenialsMode 'allow' $log = Join-Path $ScratchRoot 'logs\cd-allow-warning.log' @@ -177,6 +182,17 @@ function Phase-CaptureDenialsModes { function Phase-CaptureDenialsStampedPath { Section 'Phase 14c: captureDenials per-run output path' + if (-not $Script:Caps.CaptureDenialsUsable) { + foreach ($name in @( + 'the actual denials output path is reported on stderr', + 'the reported path is not the verbatim requested path (a per-run id is stamped in)', + 'two runs of the same config report different output paths')) { + Record-Result -Phase 'P14c' -Name $name -Status 'skip' ` + -Detail 'no capture provider on this host, so the request fails before an output path is resolved' + } + return + } + $rw = Join-Path $ScratchRoot 'rw' $requested = Join-Path $ScratchRoot 'rw\stamped.json' $cmd = 'cmd /c echo MXC-CD-RAN' diff --git a/tests/scripts/run_processcontainer_privilege_test.ps1 b/tests/scripts/run_processcontainer_privilege_test.ps1 index 920bcea41..2249bf584 100644 --- a/tests/scripts/run_processcontainer_privilege_test.ps1 +++ b/tests/scripts/run_processcontainer_privilege_test.ps1 @@ -69,24 +69,40 @@ function Phase-LeastPrivilege { # Documented in docs/schema.md: "Native PSEC/V2 capture cannot combine # with leastPrivilege or network.proxy. Hosts without that complete native - # set retain an eligible legacy containment tier and use guarded WPR." + # set retain an eligible legacy containment tier and use guarded WPR. If + # guarded-WPR prerequisites are unavailable, the request fails before MXC + # creates the sandbox." # - # So the combination is a tier constraint, not a rejection: the run should - # still be accepted and fall back. Asserting a rejection here would encode - # the opposite of the documented behavior. + # So the combination is never a validation error. Which of the two + # documented outcomes applies is decided by the host's guarded-WPR + # prerequisites, and both are asserted positively -- neither arm can pass + # on a run that simply fell over. $cfg = New-Config -Name 'priv-lpac-capture' -CommandLine $Script:PrivCmd -ReadWrite @($rw) ` -LeastPrivilege $true -CaptureDenialsMode 'block' $log = Join-Path $ScratchRoot 'logs\priv-lpac-capture.log' $r = Invoke-Wxc -Wxc $WxcDebug -ConfigPath $cfg -LogPath $log -TimeoutSec 60 $logText = Read-Log $log - $rejected = Test-WasRejected -Run $r -Log $logText # Test-WasRejected is false for backend_error, runner_unavailable and any # unexplained launch failure, so negating it alone proves no fallback ran. + $rejected = Test-WasRejected -Run $r -Log $logText $ran = [bool]("$($r.Stdout)" -match $Script:PrivMarker) - Record-Result -Phase 'P15a' -Name 'leastPrivilege + captureDenials falls back rather than being rejected' ` - -Pass ((-not $rejected) -and $ran) ` - -Detail ("exit=$($r.ExitCode); rejectedAtValidation=$rejected; workloadRan=$ran; " + - 'documented as a tier constraint (guarded WPR fallback), not a validation error') + if ($Script:Caps.GuardedCaptureAvailable) { + Record-Result -Phase 'P15a' -Name 'leastPrivilege + captureDenials falls back to guarded WPR rather than being rejected' ` + -Pass ((-not $rejected) -and $ran) ` + -Detail ("exit=$($r.ExitCode); rejectedAtValidation=$rejected; workloadRan=$ran; " + + 'guarded WPR is available on this host, so the documented tier constraint applies') + } else { + # The dispatcher's own wording for the preflight refusal. Matching it + # distinguishes the documented fail-closed from any other launch + # failure, which would otherwise look identical (no workload, non-zero + # exit). + $all = "$logText`n$($r.Stderr)" + $m = [regex]::Match($all, '(?is)captureDenials.{0,160}?(is unavailable|does not support denial capture)') + Record-Result -Phase 'P15a' -Name 'leastPrivilege + captureDenials fails before sandbox creation when guarded WPR is unavailable' ` + -Pass ($m.Success -and -not $ran) ` + -Detail ("exit=$($r.ExitCode); workloadRan=$ran; " + + "reason=$(if ($m.Success) { $m.Value -replace '\s+', ' ' } else { '' })") + } } diff --git a/tests/scripts/run_processcontainer_ui_mitigations_test.ps1 b/tests/scripts/run_processcontainer_ui_mitigations_test.ps1 index b9385aab2..a94e1d11b 100644 --- a/tests/scripts/run_processcontainer_ui_mitigations_test.ps1 +++ b/tests/scripts/run_processcontainer_ui_mitigations_test.ps1 @@ -74,7 +74,7 @@ function Phase-UiMitigationMatrix { $matrixA = @{} foreach ($line in ($rA.Stdout -split "`r?`n")) { - if ($line -match '^(?READCLIPBOARD|WRITECLIPBOARD|SYSTEMPARAMETERS|DISPLAYSETTINGS|DESKTOP|EXITWINDOWS|HANDLES|INJECTION|WIN32K)=(?PASS|FAIL)\s*$') { + if ($line -match '^(?READCLIPBOARD|WRITECLIPBOARD|SYSTEMPARAMETERS|DISPLAYSETTINGS|DESKTOP|EXITWINDOWS|HANDLES|INJECTION|WIN32K)=(?PASS|FAIL|INCONCLUSIVE)\s*$') { $matrixA[$matches['k']] = $matches['v'] } } @@ -89,6 +89,17 @@ function Phase-UiMitigationMatrix { $got = if ($matrixA.ContainsKey($tag)) { $matrixA[$tag] } else { '' } $gotV = Format-Verdict $got 'blocked' 'allowed' $fullV = Format-VerdictSummary $summaryA 'blocked' 'allowed' + # INCONCLUSIVE means the probe never reached the operation the limit + # governs -- the surrounding security environment refused a + # precondition -- so the verdict says nothing about the UILIMIT bit. + # A missing tag still fails: the probe was asked for it and produced + # nothing at all. + if ($got -eq 'INCONCLUSIVE') { + $diag = if ($rA.Stdout -match "(?m)^$tag=DIAG\s+(?.+?)\s*$") { $matches['d'] } else { '' } + Record-Result -Phase 'P4b' -Name "scenarioA: $tag" -Status 'skip' ` + -Detail "expected=blocked; the probe could not exercise $tag on this host; diag=$diag; full=$fullV" + continue + } Record-Result -Phase 'P4b' -Name "scenarioA: $tag" -Pass ($got -eq 'PASS') -Detail "expected=blocked; got=$gotV; full=$fullV" } diff --git a/tests/scripts/run_processcontainer_ui_policy_matrix_test.ps1 b/tests/scripts/run_processcontainer_ui_policy_matrix_test.ps1 index f49049c8e..0d2d149d5 100644 --- a/tests/scripts/run_processcontainer_ui_policy_matrix_test.ps1 +++ b/tests/scripts/run_processcontainer_ui_policy_matrix_test.ps1 @@ -12,7 +12,13 @@ # the thing you keep": clipboard=read allows reading and blocks writing. # # Allow-direction coverage matters: a flag wired to the wrong bit, or set -# unconditionally, still passes every blocked-direction assertion in 4b. +# unconditionally, still passes every blocked-direction assertion in 4b. It is +# only decidable where the host can actually perform the operation: a permissive +# knob omits the matching UILIMIT bit, it does not grant the capability, and the +# rest of the process-container security environment may still deny it +# (docs/process-container/UIPolicy_Schema.md). Measure-UiGrantable establishes +# which capabilities are reachable at all, and the cases below skip the allow +# direction for the rest. # # SAFETY: EXITWINDOWS is never probed where it is expected to be allowed — # that would be a real logoff of the operator's session. Those cases record an @@ -38,17 +44,23 @@ Set-StrictMode -Version Latest Initialize-WpcContext @PSBoundParameters +# Probe tag -> whether a fully permissive UI policy could exercise it on this +# host. Populated by Measure-UiGrantable; tags it does not measure are absent +# and stay under strict assertion. +$Script:UiGrantable = @{} -# Assert the documented floor: with every UI knob permissive, a contained -# process gets every UI capability. + +# Measure which UI capabilities a contained process can exercise when every UI +# knob is permissive. # -# UIPolicy_Schema.md:95 maps clipboard="all" to no UILIMIT flags and "Allowed" -# for both directions; os-version-support.md:185 marks clipboard, systemSettings -# and desktopSystemControl supported on every build. No doc says containment -# removes them. So anything still denied here is a real defect, not a quirk of -# the test host, and it is reported as one -- it also explains at a glance why -# the individual allow cases below went red. -function Measure-UiReachable { +# This is a host measurement, not an assertion. A permissive value only means +# MXC omits the matching JOB_OBJECT_UILIMIT bit; it never grants the operation, +# and the surrounding process-container security environment may still deny it. +# A capability denied here is therefore undecidable in the allow direction on +# this host, and an unexercisable probe says nothing about the block direction +# either — the per-knob cases skip those rather than report a containment +# defect. A capability that IS reachable keeps both directions strict. +function Measure-UiGrantable { $tags = @('READCLIPBOARD', 'WRITECLIPBOARD', 'SYSTEMPARAMETERS', 'DISPLAYSETTINGS', 'DESKTOP') $cfg = New-Config -Name 'ui-policy-baseline' -CommandLine "`"$UiProbeDebug`" $($tags -join ' ')" ` -ReadWrite @((Join-Path $ScratchRoot 'rw')) -Env (Get-ProbeEnvWithDestructive) ` @@ -58,21 +70,33 @@ function Measure-UiReachable { Set-UiProbeClipboardSeed $r = Invoke-Wxc -Wxc $WxcDebug -ConfigPath $cfg -LogPath $log - $verdicts = @([regex]::Matches($r.Stdout, '(?m)^[A-Z][A-Z0-9_]*=(?:PASS|FAIL|INCONCLUSIVE)\s*$')) - if ($r.ExitCode -ne 0 -or $verdicts.Count -eq 0) { - Record-Result -Phase 'P4e' -Name 'baseline: a fully permissive UI policy grants every UI capability' -Pass $false ` - -Detail "the baseline case did not run (exit=$($r.ExitCode); verdicts=$($verdicts.Count))" + $verdicts = @{} + foreach ($line in ($r.Stdout -split "`r?`n")) { + if ($line -match '^(?[A-Z][A-Z0-9_]*)=(?PASS|FAIL|INCONCLUSIVE)\s*$') { + $verdicts[$matches['k']] = $matches['v'] + } + } + $absent = @($tags | Where-Object { -not $verdicts.ContainsKey($_) }) + if ($r.ExitCode -ne 0 -or $absent.Count) { + # A baseline that did not report on every tag cannot establish what the + # host denies, and leaving $Script:UiGrantable empty keeps every case + # below strict rather than silently dropping allow-direction coverage. + Record-Result -Phase 'P4e' -Name 'baseline: the fully permissive UI policy case reports every tag' -Pass $false ` + -Detail "exit=$($r.ExitCode); missing=$(if ($absent.Count) { $absent -join ', ' } else { '' })" return } # FAIL from the probe means the operation succeeded, i.e. the capability is - # granted -- which is what a permissive policy must produce. - $denied = @($tags | Where-Object { $r.Stdout -notmatch "(?m)^$_=FAIL\s*$" }) + # reachable with no UILIMIT bit in the way. + foreach ($tag in $tags) { + $Script:UiGrantable[$tag] = ($verdicts[$tag] -eq 'FAIL') + } - $shown = if ($denied.Count) { $denied -join ', ' } else { '' } - Record-Result -Phase 'P4e' -Name 'baseline: a fully permissive UI policy grants every UI capability' ` - -Pass ($denied.Count -eq 0) ` - -Detail "still denied: $shown (UIPolicy_Schema.md:95 and os-version-support.md:185 document these as allowed)" + $reachable = @($tags | Where-Object { $Script:UiGrantable[$_] }) + $denied = @($tags | Where-Object { -not $Script:UiGrantable[$_] }) + Record-Result -Phase 'P4e' -Name 'baseline: UI capabilities reachable under a fully permissive policy' -Status 'skip' ` + -Detail ("reachable: $(if ($reachable.Count) { $reachable -join ', ' } else { '' }); " + + "denied by this host regardless of policy: $(if ($denied.Count) { $denied -join ', ' } else { '' })") } @@ -130,6 +154,9 @@ function Invoke-UiPolicyCase { $got = if ($matrix.ContainsKey($tag)) { $matrix[$tag] } else { '' } $gotV = Format-Verdict $got 'blocked' 'allowed' $name = "$($Case.Label) -> $tag=$want" + # Measured tags only; anything Measure-UiGrantable does not cover (such + # as HANDLES, which has its own negative control) stays strict. + $hostDenies = ($Script:UiGrantable.ContainsKey($tag) -and -not $Script:UiGrantable[$tag]) # INJECTION is the one tag whose verdict is not a clean two-state. # Mirror Phase 4b: an unsupported build or a lost foreground race is a @@ -161,6 +188,21 @@ function Invoke-UiPolicyCase { -Detail "expected=blocked; GUI subsystem unavailable (Win32k mitigation); diag=$diag" continue } + if ($hostDenies) { + Record-Result -Phase $Phase -Name $name -Status 'skip' ` + -Detail "expected=$want; this host denies $tag even under a fully permissive UI policy, so the probe cannot exercise the UILIMIT mapping; diag=$diag" + continue + } + # The read probe needs CF_UNICODETEXT on the clipboard the contained + # process sees. The clipboard is per-window-station and the + # contained process does not share the harness's, so a host-side + # seed never reaches it and the verdict reports an unmet + # precondition rather than a policy outcome. + if ($diag -match 'seed Unicode text') { + Record-Result -Phase $Phase -Name $name -Status 'skip' ` + -Detail "expected=$want; $tag opened the clipboard but found no CF_UNICODETEXT: the contained process has its own window station, so the harness's host-side seed is not on the clipboard it sees; diag=$diag" + continue + } # Nothing documents the probe being unable to run here, so this is a # finding rather than a skip. Record-Result -Phase $Phase -Name $name -Pass $false ` @@ -168,10 +210,15 @@ function Invoke-UiPolicyCase { continue } - # A contained process still gets the documented capability when the knob - # is permissive (UIPolicy_Schema.md:95, os-version-support.md:185). - # Measure-UiReachable reports the contradiction once; the per-knob cases - # below stay red so a real regression is never hidden behind a skip. + # A permissive knob omits the UILIMIT bit; it does not grant the + # operation. Where the host denies the capability regardless of policy, + # the allow direction carries no information about the mapping. The + # block direction stays strict: a succeeded operation is a real leak. + if ($hostDenies -and $want -eq 'allowed') { + Record-Result -Phase $Phase -Name $name -Status 'skip' ` + -Detail "this host denies $tag even under a fully permissive UI policy; got=$gotV; full=$summaryV" + continue + } $expectToken = if ($want -eq 'blocked') { 'PASS' } else { 'FAIL' } Record-Result -Phase $Phase -Name $name -Pass ($got -eq $expectToken) ` @@ -187,7 +234,7 @@ function Invoke-UiPolicyCase { function Phase-UiPolicyMatrix { Section 'Phase 4e: UI policy resolution matrix (every documented value)' - Measure-UiReachable + Measure-UiGrantable # The HANDLES probe needs a USER handle owned by a process OUTSIDE the # job. Created once and shared by every case; harmless for the rest. @@ -244,8 +291,10 @@ function Phase-UiPolicyMatrix { Expect = @{ SYSTEMPARAMETERS = 'blocked'; DISPLAYSETTINGS = 'blocked' } } # --- processContainer.ui.desktopSystemControl ----------------- - # true unblocks BOTH desktop switching and logoff. Only DESKTOP is - # probed: CreateDesktopW is reversible, ExitWindowsEx is not. + # true omits the DESKTOP and EXITWINDOWS limits for both desktop + # switching and logoff; it does not enable either operation. Only + # DESKTOP is probed: CreateDesktopW is reversible, ExitWindowsEx + # is not. @{ Name = 'deskctl-on'; Label = 'pcUi.desktopSystemControl=true'; BpUiDesktopControl = $true Expect = @{ DESKTOP = 'allowed' } } @{ Name = 'deskctl-off'; Label = 'pcUi.desktopSystemControl=false'; BpUiDesktopControl = $false From 809b819aed2637a7bd0aaa1379a3a7a6e6959fe4 Mon Sep 17 00:00:00 2001 From: Elliot Michlin <31219104+theelliotm@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:24:35 -0700 Subject: [PATCH 2/9] test(ui-probe): decide the clipboard read verdict on refusal, not round trip Requiring a readable round trip to call the read path allowed is a stronger precondition than some hosts can satisfy. On a prerelease process-container host both permissive cases failed it for different reasons: with the write limit set the self-seed is refused and the container's clipboard is genuinely empty (formats=0, gle=1168), and without it the seed lands but the data handle comes back unreadable (formats=4, gle=6). Neither call was refused. JOB_OBJECT_UILIMIT_READCLIPBOARD denies with ERROR_ACCESS_DENIED, so treat any other failure as the limit being absent and report it as such. The clipboard=none and clipboard=write cases are the standing control for that denial, and both still pass on the affected host. INCONCLUSIVE now means only that OpenClipboard itself was denied, which is an environment denial the harness already skips, so drop the branches that classified a missing seed. Also trim the comments added by the two preceding commits. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/testing/wxc_ui_probe/src/main.rs | 183 +++++++++++++++++- .../lib/WinProcessContainer.Common.ps1 | 14 +- .../lib/WinProcessContainer.Native.ps1 | 3 + .../run_processcontainer_privilege_test.ps1 | 13 +- ...processcontainer_ui_policy_matrix_test.ps1 | 31 +-- 5 files changed, 200 insertions(+), 44 deletions(-) diff --git a/src/testing/wxc_ui_probe/src/main.rs b/src/testing/wxc_ui_probe/src/main.rs index cea5aa379..5618f7342 100644 --- a/src/testing/wxc_ui_probe/src/main.rs +++ b/src/testing/wxc_ui_probe/src/main.rs @@ -148,6 +148,8 @@ struct WndClassExW { } const CF_UNICODETEXT: u32 = 13; +/// Text the read probe puts on the clipboard under test before reading it back. +const SEED_CLIPBOARD_TEXT: &str = "MXC_UI_PROBE_READ_SEED"; const GMEM_MOVEABLE: u32 = 0x0002; const SPI_SETMOUSESPEED: u32 = 0x0071; const SPIF_SENDCHANGE: u32 = 0x0002; @@ -368,10 +370,149 @@ fn probe_globalatoms(args: &ProbeArgs) { } } +/// Puts `CF_UNICODETEXT` on whatever clipboard the *calling* process sees. +/// +/// The clipboard is per-window-station, so a seed placed by the harness is not +/// necessarily on the one a contained process reads. Seeding from inside is the +/// only way to guarantee the format is present on the clipboard under test. +/// +/// This travels the write path, so `JOB_OBJECT_UILIMIT_WRITECLIPBOARD` blocks +/// it; callers report that rather than treating it as fatal. +fn seed_unicode_clipboard(user32: Hmodule) -> Result<(), String> { + type OpenClipboardFn = unsafe extern "system" fn(Hwnd) -> Bool; + type CloseClipboardFn = unsafe extern "system" fn() -> Bool; + type EmptyClipboardFn = unsafe extern "system" fn() -> Bool; + type SetClipboardDataFn = unsafe extern "system" fn(u32, Hglobal) -> Hglobal; + #[allow(clippy::type_complexity)] + type CreateWindowExWFn = unsafe extern "system" fn( + u32, + *const u16, + *const u16, + u32, + i32, + i32, + i32, + i32, + Hwnd, + *mut c_void, + Hmodule, + *mut c_void, + ) -> Hwnd; + type DestroyWindowFn = unsafe extern "system" fn(Hwnd) -> Bool; + + let resolve = |name: &str| get_proc(user32, name).ok_or(format!("{name} not resolvable")); + let open = + unsafe { std::mem::transmute::(resolve("OpenClipboard")?) }; + let empty = + unsafe { std::mem::transmute::(resolve("EmptyClipboard")?) }; + let set_data = + unsafe { std::mem::transmute::(resolve("SetClipboardData")?) }; + let create_window = + unsafe { std::mem::transmute::(resolve("CreateWindowExW")?) }; + let destroy_window = + unsafe { std::mem::transmute::(resolve("DestroyWindow")?) }; + let close = get_proc(user32, "CloseClipboard") + .map(|p| unsafe { std::mem::transmute::(p) }); + + // SetClipboardData requires an owner window the calling thread owns. + let class_name = to_wide("STATIC"); + let window_name = to_wide("MxcUiClipboardSeed"); + let owner = unsafe { + create_window( + 0, + class_name.as_ptr(), + window_name.as_ptr(), + 0, + 0, + 0, + 0, + 0, + -3isize as Hwnd, + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + ) + }; + if owner.is_null() { + return Err(format!("CreateWindowExW failed gle={}", unsafe { + GetLastError() + })); + } + + let finish = |result: Result<(), String>| { + if let Some(close) = close { + unsafe { + let _ = close(); + } + } + unsafe { + let _ = destroy_window(owner); + } + result + }; + + unsafe { + SetLastError(0); + } + if unsafe { open(owner) } == 0 { + let error = unsafe { GetLastError() }; + unsafe { + let _ = destroy_window(owner); + } + return Err(format!("OpenClipboard failed gle={error}")); + } + + let text = to_wide(SEED_CLIPBOARD_TEXT); + let byte_count = text.len() * std::mem::size_of::(); + let memory = unsafe { GlobalAlloc(GMEM_MOVEABLE, byte_count) }; + if memory.is_null() { + return finish(Err(format!("GlobalAlloc failed gle={}", unsafe { + GetLastError() + }))); + } + let buffer = unsafe { GlobalLock(memory) }; + if buffer.is_null() { + let error = unsafe { GetLastError() }; + unsafe { + let _ = GlobalFree(memory); + } + return finish(Err(format!("GlobalLock failed gle={error}"))); + } + unsafe { + std::ptr::copy_nonoverlapping(text.as_ptr() as *const u8, buffer as *mut u8, byte_count); + let _ = GlobalUnlock(memory); + } + + unsafe { + SetLastError(0); + } + if unsafe { empty() } == 0 { + let error = unsafe { GetLastError() }; + unsafe { + let _ = GlobalFree(memory); + } + return finish(Err(format!("EmptyClipboard failed gle={error}"))); + } + + unsafe { + SetLastError(0); + } + if unsafe { set_data(CF_UNICODETEXT, memory) }.is_null() { + let error = unsafe { GetLastError() }; + unsafe { + let _ = GlobalFree(memory); + } + return finish(Err(format!("SetClipboardData failed gle={error}"))); + } + finish(Ok(())) +} + fn probe_readclipboard(user32: Hmodule) { type OpenClipboardFn = unsafe extern "system" fn(Hwnd) -> Bool; type CloseClipboardFn = unsafe extern "system" fn() -> Bool; type GetClipboardDataFn = unsafe extern "system" fn(u32) -> Hglobal; + type IsClipboardFormatAvailableFn = unsafe extern "system" fn(u32) -> Bool; + type CountClipboardFormatsFn = unsafe extern "system" fn() -> i32; let open = match get_proc(user32, "OpenClipboard") { Some(p) => unsafe { std::mem::transmute::(p) }, @@ -391,6 +532,12 @@ fn probe_readclipboard(user32: Hmodule) { return; } }; + let is_available = get_proc(user32, "IsClipboardFormatAvailable") + .map(|p| unsafe { std::mem::transmute::(p) }); + let count_formats = get_proc(user32, "CountClipboardFormats") + .map(|p| unsafe { std::mem::transmute::(p) }); + + let seed = seed_unicode_clipboard(user32); unsafe { SetLastError(0); @@ -405,6 +552,10 @@ fn probe_readclipboard(user32: Hmodule) { emit_inconclusive("READCLIPBOARD"); return; } + // Sampled before the fetch so "is the format even here" is answered + // independently of why the fetch failed. + let advertised = is_available.map(|f| unsafe { f(CF_UNICODETEXT) } != 0); + let format_count = count_formats.map(|f| unsafe { f() }); unsafe { SetLastError(0); } @@ -418,17 +569,43 @@ fn probe_readclipboard(user32: Hmodule) { if !data.is_null() { emit_diag("READCLIPBOARD", "GetClipboardData returned clipboard text"); emit_fail("READCLIPBOARD"); - } else if error == ERROR_ACCESS_DENIED { + return; + } + if error == ERROR_ACCESS_DENIED { emit_pass("READCLIPBOARD"); + return; + } + + let seed_state = match &seed { + Ok(()) => "seeded in-process".to_string(), + Err(reason) => format!("in-process seed failed: {reason}"), + }; + let counted = match format_count { + Some(n) => n.to_string(), + None => "?".to_string(), + }; + if advertised == Some(true) { + emit_diag( + "READCLIPBOARD", + &format!( + "CF_UNICODETEXT is advertised but the data handle is not readable (gle={error}); \ + formats={counted}; {seed_state}" + ), + ); } else { emit_diag( "READCLIPBOARD", &format!( - "GetClipboardData returned NULL with gle={error}; seed Unicode text before probing" + "no CF_UNICODETEXT on the clipboard this process sees (gle={error}); \ + formats={counted}; {seed_state}" ), ); - emit_inconclusive("READCLIPBOARD"); } + // JOB_OBJECT_UILIMIT_READCLIPBOARD denies with ERROR_ACCESS_DENIED, which + // the clipboard=none cases exercise as a standing control. Any other error + // means the limit did not refuse this call, even where nothing readable was + // present to complete the round trip. + emit_fail("READCLIPBOARD"); } fn probe_writeclipboard(user32: Hmodule) { diff --git a/tests/scripts/lib/WinProcessContainer.Common.ps1 b/tests/scripts/lib/WinProcessContainer.Common.ps1 index 469aa9732..497eacca2 100644 --- a/tests/scripts/lib/WinProcessContainer.Common.ps1 +++ b/tests/scripts/lib/WinProcessContainer.Common.ps1 @@ -357,10 +357,8 @@ function Get-HostCapabilities { $p.probes.uiCapabilities.PSObject.Properties['canBlockInputInjection']) { $canInject = [bool]$p.probes.uiCapabilities.canBlockInputInjection } - # The two independent captureDenials providers. Probed from the debug - # binary because guarded capture resolves plm.exe next to the loaded - # module, so the release sidecar's trust result does not answer for the - # binary these tests actually run. + # Guarded capture resolves plm.exe next to the loaded module, so the release + # sidecar's trust result does not answer for the binary these tests run. $pd = Invoke-Probe -Wxc $WxcDebug -Phase 'P0' -Name 'host-capabilities (capture providers)' $nativeCapture = $false $guardedCapture = $false @@ -395,11 +393,9 @@ function Get-HostCapabilities { # EnumeratePathsUnsupported). So it is available only where the host # both selects base-container and advertises the capability. SupportsEnumeratePaths = (($tier -eq 'base-container') -and $enumBit) - # Native PSEC/V2 capture is reachable only on base-container; every - # AppContainer tier needs the guarded WPR fallback. Where neither - # provider is present a captureDenials request fails before MXC - # creates the sandbox (docs/schema.md), so the behavioral assertions - # that need a capture session to exist have nothing to observe. + # Native capture is reachable only on base-container; every AppContainer + # tier needs the guarded WPR fallback. With neither, a captureDenials + # request fails before the sandbox exists (docs/schema.md). NativeCaptureAvailable = $nativeCapture GuardedCaptureAvailable = $guardedCapture CaptureDenialsUsable = ((($tier -eq 'base-container') -and $nativeCapture) -or $guardedCapture) diff --git a/tests/scripts/lib/WinProcessContainer.Native.ps1 b/tests/scripts/lib/WinProcessContainer.Native.ps1 index 6829abdff..6f1ffd2e1 100644 --- a/tests/scripts/lib/WinProcessContainer.Native.ps1 +++ b/tests/scripts/lib/WinProcessContainer.Native.ps1 @@ -105,6 +105,9 @@ namespace Mxc { } function Set-UiProbeClipboardSeed { + # The probe seeds the clipboard it can actually see. This host-side seed + # additionally covers the one case the probe cannot seed for itself: a + # shared clipboard with JOB_OBJECT_UILIMIT_WRITECLIPBOARD set. $token = "MXC_UI_PROBE_READ_$([guid]::NewGuid().ToString('N'))" Set-Clipboard -Value $token } diff --git a/tests/scripts/run_processcontainer_privilege_test.ps1 b/tests/scripts/run_processcontainer_privilege_test.ps1 index 2249bf584..c397f834e 100644 --- a/tests/scripts/run_processcontainer_privilege_test.ps1 +++ b/tests/scripts/run_processcontainer_privilege_test.ps1 @@ -73,10 +73,9 @@ function Phase-LeastPrivilege { # guarded-WPR prerequisites are unavailable, the request fails before MXC # creates the sandbox." # - # So the combination is never a validation error. Which of the two - # documented outcomes applies is decided by the host's guarded-WPR - # prerequisites, and both are asserted positively -- neither arm can pass - # on a run that simply fell over. + # So the combination is never a validation error; which outcome applies + # depends on the host, and both arms are asserted positively so neither + # passes on a run that simply fell over. $cfg = New-Config -Name 'priv-lpac-capture' -CommandLine $Script:PrivCmd -ReadWrite @($rw) ` -LeastPrivilege $true -CaptureDenialsMode 'block' $log = Join-Path $ScratchRoot 'logs\priv-lpac-capture.log' @@ -92,10 +91,8 @@ function Phase-LeastPrivilege { -Detail ("exit=$($r.ExitCode); rejectedAtValidation=$rejected; workloadRan=$ran; " + 'guarded WPR is available on this host, so the documented tier constraint applies') } else { - # The dispatcher's own wording for the preflight refusal. Matching it - # distinguishes the documented fail-closed from any other launch - # failure, which would otherwise look identical (no workload, non-zero - # exit). + # Matching the dispatcher's own wording distinguishes the documented + # fail-closed from any other launch failure, which looks identical. $all = "$logText`n$($r.Stderr)" $m = [regex]::Match($all, '(?is)captureDenials.{0,160}?(is unavailable|does not support denial capture)') Record-Result -Phase 'P15a' -Name 'leastPrivilege + captureDenials fails before sandbox creation when guarded WPR is unavailable' ` diff --git a/tests/scripts/run_processcontainer_ui_policy_matrix_test.ps1 b/tests/scripts/run_processcontainer_ui_policy_matrix_test.ps1 index 0d2d149d5..40b0daed4 100644 --- a/tests/scripts/run_processcontainer_ui_policy_matrix_test.ps1 +++ b/tests/scripts/run_processcontainer_ui_policy_matrix_test.ps1 @@ -45,21 +45,15 @@ Set-StrictMode -Version Latest Initialize-WpcContext @PSBoundParameters # Probe tag -> whether a fully permissive UI policy could exercise it on this -# host. Populated by Measure-UiGrantable; tags it does not measure are absent -# and stay under strict assertion. +# host. Tags Measure-UiGrantable does not cover are absent and stay strict. $Script:UiGrantable = @{} # Measure which UI capabilities a contained process can exercise when every UI -# knob is permissive. -# -# This is a host measurement, not an assertion. A permissive value only means -# MXC omits the matching JOB_OBJECT_UILIMIT bit; it never grants the operation, -# and the surrounding process-container security environment may still deny it. -# A capability denied here is therefore undecidable in the allow direction on -# this host, and an unexercisable probe says nothing about the block direction -# either — the per-knob cases skip those rather than report a containment -# defect. A capability that IS reachable keeps both directions strict. +# knob is permissive. This is a host measurement, not an assertion: a permissive +# value only omits the matching UILIMIT bit, so a capability the host denies +# anyway is undecidable in the allow direction. Reachable capabilities keep both +# directions strict. function Measure-UiGrantable { $tags = @('READCLIPBOARD', 'WRITECLIPBOARD', 'SYSTEMPARAMETERS', 'DISPLAYSETTINGS', 'DESKTOP') $cfg = New-Config -Name 'ui-policy-baseline' -CommandLine "`"$UiProbeDebug`" $($tags -join ' ')" ` @@ -78,9 +72,8 @@ function Measure-UiGrantable { } $absent = @($tags | Where-Object { -not $verdicts.ContainsKey($_) }) if ($r.ExitCode -ne 0 -or $absent.Count) { - # A baseline that did not report on every tag cannot establish what the - # host denies, and leaving $Script:UiGrantable empty keeps every case - # below strict rather than silently dropping allow-direction coverage. + # Leaving the map empty keeps every case below strict rather than + # silently dropping allow-direction coverage. Record-Result -Phase 'P4e' -Name 'baseline: the fully permissive UI policy case reports every tag' -Pass $false ` -Detail "exit=$($r.ExitCode); missing=$(if ($absent.Count) { $absent -join ', ' } else { '' })" return @@ -193,16 +186,6 @@ function Invoke-UiPolicyCase { -Detail "expected=$want; this host denies $tag even under a fully permissive UI policy, so the probe cannot exercise the UILIMIT mapping; diag=$diag" continue } - # The read probe needs CF_UNICODETEXT on the clipboard the contained - # process sees. The clipboard is per-window-station and the - # contained process does not share the harness's, so a host-side - # seed never reaches it and the verdict reports an unmet - # precondition rather than a policy outcome. - if ($diag -match 'seed Unicode text') { - Record-Result -Phase $Phase -Name $name -Status 'skip' ` - -Detail "expected=$want; $tag opened the clipboard but found no CF_UNICODETEXT: the contained process has its own window station, so the harness's host-side seed is not on the clipboard it sees; diag=$diag" - continue - } # Nothing documents the probe being unable to run here, so this is a # finding rather than a skip. Record-Result -Phase $Phase -Name $name -Pass $false ` From c499de7d28dea1b8a6368946c880ee8062d60fe8 Mon Sep 17 00:00:00 2001 From: Elliot Michlin <31219104+theelliotm@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:31:06 -0700 Subject: [PATCH 3/9] test(ui-policy): warn when a read verdict rests on absence of refusal READCLIPBOARD reports allowed when GetClipboardData was not refused, which establishes the UILIMIT mapping but not the read path. Recording that as a plain PASS named "READCLIPBOARD=allowed" reads as a verified round trip and drops the probe diag that says otherwise. Record it as a warn carrying the diag. The run still does not fail: the mapping under test is correct, and the blocked direction is untouched. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../run_processcontainer_ui_policy_matrix_test.ps1 | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/scripts/run_processcontainer_ui_policy_matrix_test.ps1 b/tests/scripts/run_processcontainer_ui_policy_matrix_test.ps1 index 40b0daed4..41e8e10e4 100644 --- a/tests/scripts/run_processcontainer_ui_policy_matrix_test.ps1 +++ b/tests/scripts/run_processcontainer_ui_policy_matrix_test.ps1 @@ -204,6 +204,17 @@ function Invoke-UiPolicyCase { } $expectToken = if ($want -eq 'blocked') { 'PASS' } else { 'FAIL' } + if ($got -eq $expectToken -and $want -eq 'allowed') { + # The read probe reports "allowed" when the limit did not refuse the + # call, which proves the mapping but not the read path. Say so + # rather than rendering it as a verified round trip. + $diag = if ($r.Stdout -match "(?m)^$tag=DIAG\s+(?.+?)\s*$") { $matches['d'] } else { '' } + if ($diag -match 'CF_UNICODETEXT') { + Record-Result -Phase $Phase -Name $name -Status 'warn' ` + -Detail "the $tag limit did not refuse the call, but no readable text confirmed the path; diag=$diag; full=$summaryV" + continue + } + } Record-Result -Phase $Phase -Name $name -Pass ($got -eq $expectToken) ` -Detail "expected=$want; got=$gotV; full=$summaryV" } From 5aabef9f3f18b458ff615c8118ea43dd8686b06f Mon Sep 17 00:00:00 2001 From: Elliot Michlin <31219104+theelliotm@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:35:56 -0700 Subject: [PATCH 4/9] test(ui-probe): keep the clipboard seed owner alive across the read The read probe destroyed the seed's owner window before reading the data back. This process pumps no messages, so WM_RENDERALLFORMATS and WM_DESTROYCLIPBOARD go unhandled and the data can become unreadable while the format list still advertises it -- which is what a prerelease process-container host reported: formats=4 from a seed that succeeded, and GetClipboardData failing with ERROR_INVALID_HANDLE. Return the owner from the seed and destroy it only after the read, so a single run writes and reads back the same clipboard with the owner alive throughout. Failure paths still tear it down immediately. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/testing/wxc_ui_probe/src/main.rs | 48 +++++++++++++++++++++------- 1 file changed, 37 insertions(+), 11 deletions(-) diff --git a/src/testing/wxc_ui_probe/src/main.rs b/src/testing/wxc_ui_probe/src/main.rs index 5618f7342..8909dd229 100644 --- a/src/testing/wxc_ui_probe/src/main.rs +++ b/src/testing/wxc_ui_probe/src/main.rs @@ -370,15 +370,21 @@ fn probe_globalatoms(args: &ProbeArgs) { } } -/// Puts `CF_UNICODETEXT` on whatever clipboard the *calling* process sees. +/// Puts `CF_UNICODETEXT` on whatever clipboard the *calling* process sees and +/// returns the owner window, which the caller must keep alive until it has read +/// the data back and then destroy. /// /// The clipboard is per-window-station, so a seed placed by the harness is not /// necessarily on the one a contained process reads. Seeding from inside is the /// only way to guarantee the format is present on the clipboard under test. /// +/// The owner outlives the seed deliberately: this process pumps no messages, so +/// destroying it early leaves `WM_RENDERALLFORMATS` unhandled and the data can +/// go unreadable while the format list still advertises it. +/// /// This travels the write path, so `JOB_OBJECT_UILIMIT_WRITECLIPBOARD` blocks /// it; callers report that rather than treating it as fatal. -fn seed_unicode_clipboard(user32: Hmodule) -> Result<(), String> { +fn seed_unicode_clipboard(user32: Hmodule) -> Result { type OpenClipboardFn = unsafe extern "system" fn(Hwnd) -> Bool; type CloseClipboardFn = unsafe extern "system" fn() -> Bool; type EmptyClipboardFn = unsafe extern "system" fn() -> Bool; @@ -439,7 +445,9 @@ fn seed_unicode_clipboard(user32: Hmodule) -> Result<(), String> { })); } - let finish = |result: Result<(), String>| { + // Closes the clipboard and tears the owner down; only the failure paths + // take it, since a successful seed must outlive this call. + let fail = |reason: String| -> Result { if let Some(close) = close { unsafe { let _ = close(); @@ -448,7 +456,7 @@ fn seed_unicode_clipboard(user32: Hmodule) -> Result<(), String> { unsafe { let _ = destroy_window(owner); } - result + Err(reason) }; unsafe { @@ -466,9 +474,9 @@ fn seed_unicode_clipboard(user32: Hmodule) -> Result<(), String> { let byte_count = text.len() * std::mem::size_of::(); let memory = unsafe { GlobalAlloc(GMEM_MOVEABLE, byte_count) }; if memory.is_null() { - return finish(Err(format!("GlobalAlloc failed gle={}", unsafe { + return fail(format!("GlobalAlloc failed gle={}", unsafe { GetLastError() - }))); + })); } let buffer = unsafe { GlobalLock(memory) }; if buffer.is_null() { @@ -476,7 +484,7 @@ fn seed_unicode_clipboard(user32: Hmodule) -> Result<(), String> { unsafe { let _ = GlobalFree(memory); } - return finish(Err(format!("GlobalLock failed gle={error}"))); + return fail(format!("GlobalLock failed gle={error}")); } unsafe { std::ptr::copy_nonoverlapping(text.as_ptr() as *const u8, buffer as *mut u8, byte_count); @@ -491,7 +499,7 @@ fn seed_unicode_clipboard(user32: Hmodule) -> Result<(), String> { unsafe { let _ = GlobalFree(memory); } - return finish(Err(format!("EmptyClipboard failed gle={error}"))); + return fail(format!("EmptyClipboard failed gle={error}")); } unsafe { @@ -502,9 +510,14 @@ fn seed_unicode_clipboard(user32: Hmodule) -> Result<(), String> { unsafe { let _ = GlobalFree(memory); } - return finish(Err(format!("SetClipboardData failed gle={error}"))); + return fail(format!("SetClipboardData failed gle={error}")); + } + if let Some(close) = close { + unsafe { + let _ = close(); + } } - finish(Ok(())) + Ok(owner) } fn probe_readclipboard(user32: Hmodule) { @@ -513,6 +526,7 @@ fn probe_readclipboard(user32: Hmodule) { type GetClipboardDataFn = unsafe extern "system" fn(u32) -> Hglobal; type IsClipboardFormatAvailableFn = unsafe extern "system" fn(u32) -> Bool; type CountClipboardFormatsFn = unsafe extern "system" fn() -> i32; + type DestroyWindowFn = unsafe extern "system" fn(Hwnd) -> Bool; let open = match get_proc(user32, "OpenClipboard") { Some(p) => unsafe { std::mem::transmute::(p) }, @@ -536,8 +550,18 @@ fn probe_readclipboard(user32: Hmodule) { .map(|p| unsafe { std::mem::transmute::(p) }); let count_formats = get_proc(user32, "CountClipboardFormats") .map(|p| unsafe { std::mem::transmute::(p) }); + let destroy_window = get_proc(user32, "DestroyWindow") + .map(|p| unsafe { std::mem::transmute::(p) }); + // The seed's owner window stays alive until the read has completed. let seed = seed_unicode_clipboard(user32); + let release_seed = || { + if let (Ok(owner), Some(destroy)) = (&seed, destroy_window) { + unsafe { + let _ = destroy(*owner); + } + } + }; unsafe { SetLastError(0); @@ -545,6 +569,7 @@ fn probe_readclipboard(user32: Hmodule) { let opened = unsafe { open(std::ptr::null_mut()) }; let open_error = unsafe { GetLastError() }; if opened == 0 { + release_seed(); emit_diag( "READCLIPBOARD", &format!("OpenClipboard failed gle={open_error}"), @@ -566,6 +591,7 @@ fn probe_readclipboard(user32: Hmodule) { let _ = close(); } } + release_seed(); if !data.is_null() { emit_diag("READCLIPBOARD", "GetClipboardData returned clipboard text"); emit_fail("READCLIPBOARD"); @@ -577,7 +603,7 @@ fn probe_readclipboard(user32: Hmodule) { } let seed_state = match &seed { - Ok(()) => "seeded in-process".to_string(), + Ok(_) => "seeded in-process".to_string(), Err(reason) => format!("in-process seed failed: {reason}"), }; let counted = match format_count { From 29cb9fc52c744f01f8457be3f47a91bd0e0527ae Mon Sep 17 00:00:00 2001 From: Elliot Michlin <31219104+theelliotm@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:41:44 -0700 Subject: [PATCH 5/9] test(process-container): carry probe diagnostics into passing results The UI policy matrix dropped each tag's DIAG line unless the result was a warn or a skip, so a passing case said only "got=blocked" and gave no record of how the probe reached that verdict. Append the diagnostic to every recorded detail when the probe emitted one. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...run_processcontainer_ui_policy_matrix_test.ps1 | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/tests/scripts/run_processcontainer_ui_policy_matrix_test.ps1 b/tests/scripts/run_processcontainer_ui_policy_matrix_test.ps1 index 41e8e10e4..ed3a1d605 100644 --- a/tests/scripts/run_processcontainer_ui_policy_matrix_test.ps1 +++ b/tests/scripts/run_processcontainer_ui_policy_matrix_test.ps1 @@ -204,19 +204,18 @@ function Invoke-UiPolicyCase { } $expectToken = if ($want -eq 'blocked') { 'PASS' } else { 'FAIL' } - if ($got -eq $expectToken -and $want -eq 'allowed') { + $diag = if ($r.Stdout -match "(?m)^$tag=DIAG\s+(?.+?)\s*$") { $matches['d'] } else { '' } + $diagPart = if ($diag) { "; diag=$diag" } else { '' } + if ($got -eq $expectToken -and $want -eq 'allowed' -and $diag -match 'CF_UNICODETEXT') { # The read probe reports "allowed" when the limit did not refuse the # call, which proves the mapping but not the read path. Say so # rather than rendering it as a verified round trip. - $diag = if ($r.Stdout -match "(?m)^$tag=DIAG\s+(?.+?)\s*$") { $matches['d'] } else { '' } - if ($diag -match 'CF_UNICODETEXT') { - Record-Result -Phase $Phase -Name $name -Status 'warn' ` - -Detail "the $tag limit did not refuse the call, but no readable text confirmed the path; diag=$diag; full=$summaryV" - continue - } + Record-Result -Phase $Phase -Name $name -Status 'warn' ` + -Detail "the $tag limit did not refuse the call, but no readable text confirmed the path$diagPart; full=$summaryV" + continue } Record-Result -Phase $Phase -Name $name -Pass ($got -eq $expectToken) ` - -Detail "expected=$want; got=$gotV; full=$summaryV" + -Detail "expected=$want; got=$gotV$diagPart; full=$summaryV" } } From ba0621567005da925335dc9c5a7be59e8800a57f Mon Sep 17 00:00:00 2001 From: Elliot Michlin <31219104+theelliotm@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:43:48 -0700 Subject: [PATCH 6/9] test(process-container): pass a read verdict that was not refused JOB_OBJECT_UILIMIT_READCLIPBOARD denies with ERROR_ACCESS_DENIED, so any other outcome means the limit did not refuse the call -- which is what a permissive policy asserts. Warning when the probe additionally could not read text back held the allow direction to a round trip the policy does not promise, and under ui.clipboard=read nothing can legally seed that clipboard: the only writer is the one the policy blocks. The recorded detail carries the probe diagnostic either way, so the distinction stays visible without failing to report a green result. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../run_processcontainer_ui_policy_matrix_test.ps1 | 8 -------- 1 file changed, 8 deletions(-) diff --git a/tests/scripts/run_processcontainer_ui_policy_matrix_test.ps1 b/tests/scripts/run_processcontainer_ui_policy_matrix_test.ps1 index ed3a1d605..db2724279 100644 --- a/tests/scripts/run_processcontainer_ui_policy_matrix_test.ps1 +++ b/tests/scripts/run_processcontainer_ui_policy_matrix_test.ps1 @@ -206,14 +206,6 @@ function Invoke-UiPolicyCase { $expectToken = if ($want -eq 'blocked') { 'PASS' } else { 'FAIL' } $diag = if ($r.Stdout -match "(?m)^$tag=DIAG\s+(?.+?)\s*$") { $matches['d'] } else { '' } $diagPart = if ($diag) { "; diag=$diag" } else { '' } - if ($got -eq $expectToken -and $want -eq 'allowed' -and $diag -match 'CF_UNICODETEXT') { - # The read probe reports "allowed" when the limit did not refuse the - # call, which proves the mapping but not the read path. Say so - # rather than rendering it as a verified round trip. - Record-Result -Phase $Phase -Name $name -Status 'warn' ` - -Detail "the $tag limit did not refuse the call, but no readable text confirmed the path$diagPart; full=$summaryV" - continue - } Record-Result -Phase $Phase -Name $name -Pass ($got -eq $expectToken) ` -Detail "expected=$want; got=$gotV$diagPart; full=$summaryV" } From 60f3b675744e51a6c36f9726b30003e54dad216c Mon Sep 17 00:00:00 2001 From: Elliot Michlin <31219104+theelliotm@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:54:19 -0700 Subject: [PATCH 7/9] test(ui-probe): require CloseClipboard and escape interpolated tags CloseClipboard was resolved optionally while OpenClipboard was called unconditionally, so an unresolvable CloseClipboard would leave the clipboard open for the rest of the process. The probe runs several tags per invocation, so that would break every later clipboard tag in the same run. Resolve it up front with the other required entry points and close unconditionally. Escape the tag before interpolating it into the DIAG match so the pattern stays an exact tag match. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/testing/wxc_ui_probe/src/main.rs | 66 ++++++++----------- ...n_processcontainer_ui_mitigations_test.ps1 | 2 +- ...processcontainer_ui_policy_matrix_test.ps1 | 4 +- 3 files changed, 30 insertions(+), 42 deletions(-) diff --git a/src/testing/wxc_ui_probe/src/main.rs b/src/testing/wxc_ui_probe/src/main.rs index 8909dd229..ae3572bbf 100644 --- a/src/testing/wxc_ui_probe/src/main.rs +++ b/src/testing/wxc_ui_probe/src/main.rs @@ -417,8 +417,8 @@ fn seed_unicode_clipboard(user32: Hmodule) -> Result { unsafe { std::mem::transmute::(resolve("CreateWindowExW")?) }; let destroy_window = unsafe { std::mem::transmute::(resolve("DestroyWindow")?) }; - let close = get_proc(user32, "CloseClipboard") - .map(|p| unsafe { std::mem::transmute::(p) }); + let close = + unsafe { std::mem::transmute::(resolve("CloseClipboard")?) }; // SetClipboardData requires an owner window the calling thread owns. let class_name = to_wide("STATIC"); @@ -448,12 +448,8 @@ fn seed_unicode_clipboard(user32: Hmodule) -> Result { // Closes the clipboard and tears the owner down; only the failure paths // take it, since a successful seed must outlive this call. let fail = |reason: String| -> Result { - if let Some(close) = close { - unsafe { - let _ = close(); - } - } unsafe { + let _ = close(); let _ = destroy_window(owner); } Err(reason) @@ -512,10 +508,8 @@ fn seed_unicode_clipboard(user32: Hmodule) -> Result { } return fail(format!("SetClipboardData failed gle={error}")); } - if let Some(close) = close { - unsafe { - let _ = close(); - } + unsafe { + let _ = close(); } Ok(owner) } @@ -536,8 +530,14 @@ fn probe_readclipboard(user32: Hmodule) { return; } }; - let close = get_proc(user32, "CloseClipboard") - .map(|p| unsafe { std::mem::transmute::(p) }); + let close = match get_proc(user32, "CloseClipboard") { + Some(p) => unsafe { std::mem::transmute::(p) }, + None => { + emit_diag("READCLIPBOARD", "CloseClipboard not resolvable"); + emit_fail("READCLIPBOARD"); + return; + } + }; let get_data = match get_proc(user32, "GetClipboardData") { Some(p) => unsafe { std::mem::transmute::(p) }, None => { @@ -586,10 +586,8 @@ fn probe_readclipboard(user32: Hmodule) { } let data = unsafe { get_data(CF_UNICODETEXT) }; let error = unsafe { GetLastError() }; - if let Some(close) = close { - unsafe { - let _ = close(); - } + unsafe { + let _ = close(); } release_seed(); if !data.is_null() { @@ -680,8 +678,14 @@ fn probe_writeclipboard(user32: Hmodule) { return; } }; - let close = get_proc(user32, "CloseClipboard") - .map(|p| unsafe { std::mem::transmute::(p) }); + let close = match get_proc(user32, "CloseClipboard") { + Some(p) => unsafe { std::mem::transmute::(p) }, + None => { + emit_diag("WRITECLIPBOARD", "CloseClipboard not resolvable"); + emit_fail("WRITECLIPBOARD"); + return; + } + }; let create_window = match get_proc(user32, "CreateWindowExW") { Some(p) => unsafe { std::mem::transmute::(p) }, None => { @@ -748,12 +752,8 @@ fn probe_writeclipboard(user32: Hmodule) { let memory = unsafe { GlobalAlloc(GMEM_MOVEABLE, byte_count) }; if memory.is_null() { let error = unsafe { GetLastError() }; - if let Some(close) = close { - unsafe { - let _ = close(); - } - } unsafe { + let _ = close(); let _ = destroy_window(owner); } emit_diag("WRITECLIPBOARD", &format!("GlobalAlloc failed gle={error}")); @@ -763,12 +763,8 @@ fn probe_writeclipboard(user32: Hmodule) { let buffer = unsafe { GlobalLock(memory) }; if buffer.is_null() { let error = unsafe { GetLastError() }; - if let Some(close) = close { - unsafe { - let _ = close(); - } - } unsafe { + let _ = close(); let _ = GlobalFree(memory); let _ = destroy_window(owner); } @@ -784,12 +780,8 @@ fn probe_writeclipboard(user32: Hmodule) { let emptied = unsafe { empty() }; if emptied == 0 { let error = unsafe { GetLastError() }; - if let Some(close) = close { - unsafe { - let _ = close(); - } - } unsafe { + let _ = close(); let _ = GlobalFree(memory); let _ = destroy_window(owner); } @@ -810,12 +802,8 @@ fn probe_writeclipboard(user32: Hmodule) { } let set_ok = unsafe { set_data(CF_UNICODETEXT, memory) }; let error = unsafe { GetLastError() }; - if let Some(close) = close { - unsafe { - let _ = close(); - } - } unsafe { + let _ = close(); let _ = destroy_window(owner); } if !set_ok.is_null() { diff --git a/tests/scripts/run_processcontainer_ui_mitigations_test.ps1 b/tests/scripts/run_processcontainer_ui_mitigations_test.ps1 index a94e1d11b..0d0323eb6 100644 --- a/tests/scripts/run_processcontainer_ui_mitigations_test.ps1 +++ b/tests/scripts/run_processcontainer_ui_mitigations_test.ps1 @@ -95,7 +95,7 @@ function Phase-UiMitigationMatrix { # A missing tag still fails: the probe was asked for it and produced # nothing at all. if ($got -eq 'INCONCLUSIVE') { - $diag = if ($rA.Stdout -match "(?m)^$tag=DIAG\s+(?.+?)\s*$") { $matches['d'] } else { '' } + $diag = if ($rA.Stdout -match "(?m)^$([regex]::Escape($tag))=DIAG\s+(?.+?)\s*$") { $matches['d'] } else { '' } Record-Result -Phase 'P4b' -Name "scenarioA: $tag" -Status 'skip' ` -Detail "expected=blocked; the probe could not exercise $tag on this host; diag=$diag; full=$fullV" continue diff --git a/tests/scripts/run_processcontainer_ui_policy_matrix_test.ps1 b/tests/scripts/run_processcontainer_ui_policy_matrix_test.ps1 index db2724279..98a668fee 100644 --- a/tests/scripts/run_processcontainer_ui_policy_matrix_test.ps1 +++ b/tests/scripts/run_processcontainer_ui_policy_matrix_test.ps1 @@ -172,7 +172,7 @@ function Invoke-UiPolicyCase { } if ($got -eq 'INCONCLUSIVE') { - $diag = if ($r.Stdout -match "(?m)^$tag=DIAG\s+(?.+?)\s*$") { $matches['d'] } else { '' } + $diag = if ($r.Stdout -match "(?m)^$([regex]::Escape($tag))=DIAG\s+(?.+?)\s*$") { $matches['d'] } else { '' } # ui.disable=true engages the Win32k mitigation, which stops user32 # from loading. Losing the GUI subsystem outright is strictly # stronger than the individual limit, so it satisfies "blocked". @@ -204,7 +204,7 @@ function Invoke-UiPolicyCase { } $expectToken = if ($want -eq 'blocked') { 'PASS' } else { 'FAIL' } - $diag = if ($r.Stdout -match "(?m)^$tag=DIAG\s+(?.+?)\s*$") { $matches['d'] } else { '' } + $diag = if ($r.Stdout -match "(?m)^$([regex]::Escape($tag))=DIAG\s+(?.+?)\s*$") { $matches['d'] } else { '' } $diagPart = if ($diag) { "; diag=$diag" } else { '' } Record-Result -Phase $Phase -Name $name -Pass ($got -eq $expectToken) ` -Detail "expected=$want; got=$gotV$diagPart; full=$summaryV" From 364d1556827f86185c7ffb40863b5e4f39990fbc Mon Sep 17 00:00:00 2001 From: Elliot Michlin <31219104+theelliotm@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:09:13 -0700 Subject: [PATCH 8/9] test(process-container): require a clipboard round trip where writing is allowed ui.clipboard=all permits the probe to seed the clipboard it reads, so accepting "the limit did not refuse the call" left the read path itself unasserted: a seeded-but-unreadable handle still passed. That is exactly the ERROR_INVALID_HANDLE failure this branch fixes, which the suite would not have caught. Opt the clip-all case into requiring the seeded text to come back. A refused seed stays a skip, since the WRITECLIPBOARD assertion already owns that and there is nothing to read. ui.clipboard=read cannot take this requirement -- the only writer is the one that policy blocks. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...processcontainer_ui_policy_matrix_test.ps1 | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/scripts/run_processcontainer_ui_policy_matrix_test.ps1 b/tests/scripts/run_processcontainer_ui_policy_matrix_test.ps1 index 98a668fee..74f736af7 100644 --- a/tests/scripts/run_processcontainer_ui_policy_matrix_test.ps1 +++ b/tests/scripts/run_processcontainer_ui_policy_matrix_test.ps1 @@ -206,6 +206,25 @@ function Invoke-UiPolicyCase { $expectToken = if ($want -eq 'blocked') { 'PASS' } else { 'FAIL' } $diag = if ($r.Stdout -match "(?m)^$([regex]::Escape($tag))=DIAG\s+(?.+?)\s*$") { $matches['d'] } else { '' } $diagPart = if ($diag) { "; diag=$diag" } else { '' } + + # Where the policy also permits writing, the probe can seed the + # clipboard it reads, so "the limit did not refuse" is too weak: it + # still holds when the data comes back unreadable. Demand the round + # trip, which is the only assertion that covers the read path itself. + if ($got -eq $expectToken -and $tag -eq 'READCLIPBOARD' -and $Case['RequireClipboardRoundTrip']) { + if ($diag -match 'in-process seed failed') { + # The WRITECLIPBOARD assertion owns a refused seed; with nothing + # on the clipboard there is no round trip to require. + Record-Result -Phase $Phase -Name $name -Status 'skip' ` + -Detail "expected=$want; the probe could not seed the clipboard, so the read path was not exercised$diagPart; full=$summaryV" + continue + } + if ($diag -notmatch 'returned clipboard text') { + Record-Result -Phase $Phase -Name $name -Pass $false ` + -Detail "expected=$want with a readable round trip; the limit did not refuse the call but the seeded text did not come back$diagPart; full=$summaryV" + continue + } + } Record-Result -Phase $Phase -Name $name -Pass ($got -eq $expectToken) ` -Detail "expected=$want; got=$gotV$diagPart; full=$summaryV" } @@ -233,6 +252,7 @@ function Phase-UiPolicyMatrix { # "the named thing is the thing you keep": read => reading survives. $cases = @( @{ Name = 'clip-all'; Label = 'ui.clipboard=all'; Clipboard = 'all' + RequireClipboardRoundTrip = $true Expect = @{ READCLIPBOARD = 'allowed'; WRITECLIPBOARD = 'allowed' } } @{ Name = 'clip-read'; Label = 'ui.clipboard=read'; Clipboard = 'read' Expect = @{ READCLIPBOARD = 'allowed'; WRITECLIPBOARD = 'blocked' } } From 666697d4584fa887a7b7e5efb3cfa7e0c1dd28a4 Mon Sep 17 00:00:00 2001 From: Elliot Michlin <31219104+theelliotm@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:13:05 -0700 Subject: [PATCH 9/9] test(ui-probe): compare the clipboard read-back against the seeded text The read probe only checked that GetClipboardData returned a non-null handle, but reported "returned clipboard text" -- claiming more than it verified. Lock the handle while the clipboard is still open, decode the UTF-16, and compare it to the seed, so the round trip ui.clipboard=all requires is an actual content match rather than handle liveness. Contents are never echoed: on a shared window station they can belong to the host user, so a mismatch reports only its length. The verdict is unchanged in every case. A handle that does not match the seed still means the limit did not refuse the call. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/testing/wxc_ui_probe/src/main.rs | 43 ++++++++++++++++++- ...processcontainer_ui_policy_matrix_test.ps1 | 2 +- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/src/testing/wxc_ui_probe/src/main.rs b/src/testing/wxc_ui_probe/src/main.rs index ae3572bbf..efbfd1e39 100644 --- a/src/testing/wxc_ui_probe/src/main.rs +++ b/src/testing/wxc_ui_probe/src/main.rs @@ -586,12 +586,51 @@ fn probe_readclipboard(user32: Hmodule) { } let data = unsafe { get_data(CF_UNICODETEXT) }; let error = unsafe { GetLastError() }; + // The handle belongs to the clipboard and is only valid until CloseClipboard. + let readback = if data.is_null() { + None + } else { + let locked = unsafe { GlobalLock(data) }; + if locked.is_null() { + Some(Err(format!("GlobalLock failed gle={}", unsafe { + GetLastError() + }))) + } else { + let mut units: Vec = Vec::new(); + unsafe { + let mut cursor = locked as *const u16; + while *cursor != 0 && units.len() < 4096 { + units.push(*cursor); + cursor = cursor.add(1); + } + let _ = GlobalUnlock(data); + } + Some(Ok(String::from_utf16_lossy(&units))) + } + }; unsafe { let _ = close(); } release_seed(); - if !data.is_null() { - emit_diag("READCLIPBOARD", "GetClipboardData returned clipboard text"); + if let Some(readback) = readback { + // Never echo clipboard contents: on a shared window station they can + // be the host user's. + match readback { + Ok(text) if text == SEED_CLIPBOARD_TEXT => { + emit_diag("READCLIPBOARD", "GetClipboardData returned the seeded text") + } + Ok(text) => emit_diag( + "READCLIPBOARD", + &format!( + "GetClipboardData returned {} chars that are not the seed", + text.chars().count() + ), + ), + Err(reason) => emit_diag( + "READCLIPBOARD", + &format!("GetClipboardData returned a handle but {reason}"), + ), + } emit_fail("READCLIPBOARD"); return; } diff --git a/tests/scripts/run_processcontainer_ui_policy_matrix_test.ps1 b/tests/scripts/run_processcontainer_ui_policy_matrix_test.ps1 index 74f736af7..c8318a446 100644 --- a/tests/scripts/run_processcontainer_ui_policy_matrix_test.ps1 +++ b/tests/scripts/run_processcontainer_ui_policy_matrix_test.ps1 @@ -219,7 +219,7 @@ function Invoke-UiPolicyCase { -Detail "expected=$want; the probe could not seed the clipboard, so the read path was not exercised$diagPart; full=$summaryV" continue } - if ($diag -notmatch 'returned clipboard text') { + if ($diag -notmatch 'returned the seeded text') { Record-Result -Phase $Phase -Name $name -Pass $false ` -Detail "expected=$want with a readable round trip; the limit did not refuse the call but the seeded text did not come back$diagPart; full=$summaryV" continue