From 420f1dd8049e8b260898ebbd6d844296502aef0c Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Wed, 14 Oct 2026 17:35:07 -0700 Subject: [PATCH 1/4] Bind capture admission to coordinator-leased job identity Refs #822 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: fc358da6-6436-4c4b-8086-c4778016cd55 Signed-off-by: Colin Neilens --- Tools/windows/Invoke-UiCapture.ps1 | 138 ++++---- Tools/windows/Invoke-UiCaptureWorker.ps1 | 72 ++-- Tools/windows/Tests/UiCaptureDriver.Tests.ps1 | 193 ++++++++++- Tools/windows/UiCaptureDriver.psm1 | 314 ++++++++++++++++++ 4 files changed, 587 insertions(+), 130 deletions(-) diff --git a/Tools/windows/Invoke-UiCapture.ps1 b/Tools/windows/Invoke-UiCapture.ps1 index b4eb7820..a7d0cfed 100644 --- a/Tools/windows/Invoke-UiCapture.ps1 +++ b/Tools/windows/Invoke-UiCapture.ps1 @@ -20,11 +20,9 @@ $requestedOutputDirectory = $OutputDirectory $modulePath = Join-Path $PSScriptRoot 'UiCaptureDriver.psm1' $workerPath = Join-Path $PSScriptRoot 'Invoke-UiCaptureWorker.ps1' $rehearsal = $ExecutionMode -ceq 'NO_DESKTOP_REAL_LAUNCH_REHEARSAL' -$jobHashExpected = 'E5EF95F46F97C96337DA3195DEA7E415667B6F19F09721C853770C1819CDA40C' -$equivalenceHashExpected = '0D3C236B736DC38DCFDB36166F7593313F10A27A716DAD39664EFFC329F3B8CA' -$preflight08HashExpected = '5B3F075635007C70D7D82C5759A144B56E2D5B37074525943F6B12354776B349' $fixtureMappingHashExpected = if ($FixtureMappingSha256) { $FixtureMappingSha256.ToUpperInvariant() } - else { '962A8C7F98025FD3C523DF842E0E6E5E372CE3E37A81B3977A3039B3CD420E1F' } + else { $null } +$jobIdentity = $null $fixtureHashExpected = $null $tracked = [Collections.Generic.Dictionary[string, object]]::new([StringComparer]::OrdinalIgnoreCase) $untrackedJournalEntries = [Collections.Generic.List[object]]::new() @@ -122,87 +120,30 @@ function Get-CapSupervisorFailureText { throw 'Unexpected capture failure representation.' } -function Get-CapSourceRoot { - param([Parameter(Mandatory)][string]$BinaryPath) - if (Get-Command Write-CapTrace -ErrorAction SilentlyContinue) { Write-CapTrace -Command $MyInvocation.MyCommand.Name -Parameters $PSBoundParameters } - - $sourceRoot = [IO.Path]::GetFullPath($BinaryPath) - for ($parentDepth = 0; $parentDepth -lt 4; $parentDepth++) { $sourceRoot = Split-Path -Parent $sourceRoot } - if (-not (Test-Path -LiteralPath (Join-Path $sourceRoot '.git'))) { throw "B repository root was not found from binary path: $sourceRoot" } - return $sourceRoot -} - function Assert-CapGreenJob { if (Get-Command Write-CapTrace -ErrorAction SilentlyContinue) { Write-CapTrace -Command $MyInvocation.MyCommand.Name -Parameters $PSBoundParameters } - if ($jobHash -ne $jobHashExpected) { throw "Green B job hash mismatch: $jobHash" } - if ($job.lane -ne 'B' -or $job.head -ne 'e3ff22f278524645e4cae2690e1ff5c1aee7d66e' -or - $job.base -ne '6916bc2c2baabc2f83c92230aeee8b49e879758e') { - throw 'Green B successor job no longer matches the accepted exact head/base.' - } - if ($job.authority.centralCaptureLeaseGrantedByThisArtifact -ne $false -or - $job.authority.bWorkerMayLaunchGui -ne $false) { - throw 'Job authority must remain explicit that only a separate central lease can authorize capture.' - } - if (($job.captureContract.phases -join ',') -cne 'before,after' -or - $job.captureContract.sceneCountPerPhase -ne 5 -or - ($job.captureContract.windowOuterXYWH -join ',') -cne '30,30,1456,959' -or - ($job.captureContract.expectedClientWH -join ',') -cne '1440,900' -or - $job.captureContract.dpi -ne 96 -or $job.captureContract.canvasZoomPercent -ne 100 -or - $job.scenes.Count -ne 5 -or - (($job.scenes | ForEach-Object name) -join ',') -cne 'project-alpha,project-beta,overview,workspace,actions') { - throw 'B scene, geometry, or zoom contract differs from the reviewed five-scene job.' - } - $expectedSeedArguments = @( - 'run', 'graphcode-11111111-1111-4111-8111-000000000001', '-d', - 'cmd.exe', '/Q', '/K', 'echo Offline card fixture - no backend' - ) - if (($job.binaries.offlineZmx.seedArgumentOrder -join [char]0) -cne ($expectedSeedArguments -join [char]0)) { - throw 'The reviewed B job no longer specifies the exact offline cmd-only seed argument vector.' - } - $ci = [string]$job.freshness.ci - $review = [string]$job.freshness.narrowRebaseReview - $runner = [string]$job.freshness.focusedRunner - if ($ci -notmatch '24 completed contexts,20SUCCESS/4configured SKIPPED' -or - $ci -notmatch 'seven successful workflows' -or - $review -notmatch 'ACCEPT' -or $runner -notmatch '166G1executed/166passed/0failed') { - throw 'Green job is missing exact-head CI, focused-runner, or accepted narrow-review evidence.' - } - $equivalencePath = Join-Path $root 'inputs\g1-rebase-equivalence.json' - if ((Get-FileHash -Algorithm SHA256 -LiteralPath $equivalencePath).Hash -ne $equivalenceHashExpected) { - throw 'G1 source/binary equivalence proof changed.' - } + Assert-CapSceneContract -Job $job $mappingProof = Assert-CapFixtureMapping -Root $root -FixturePath (Join-Path $root 'fixtures\fixture.json') ` -SourcePath (Join-Path $root 'inputs\fixture-source.json') -MappingPath (Join-Path $root 'reports\fixture-mapping.json') ` -ExpectedMappingSha256 $fixtureMappingHashExpected ` - -ExpectedSourceSha256 'BFC26149FCC5AD2DE4C042E1E4F200F8C2299B662E24DA426EDB3683AA4C56FA' ` - -OriginalRoot 'C:\gcs\ib\captures' + -ExpectedSourceSha256 $job.fixture.sha256 -OriginalRoot $job.fixture.originalRoot $script:fixtureHashExpected = $mappingProof.FixtureSha256 $inputHashes = @{ fixture = @((Join-Path $root 'fixtures\fixture.json'), $fixtureHashExpected) - fixtureSource = @((Join-Path $root 'inputs\fixture-source.json'), 'BFC26149FCC5AD2DE4C042E1E4F200F8C2299B662E24DA426EDB3683AA4C56FA') + fixtureSource = @((Join-Path $root 'inputs\fixture-source.json'), $job.fixture.sha256) fixtureMapping = @((Join-Path $root 'reports\fixture-mapping.json'), $fixtureMappingHashExpected) - peer = @((Join-Path $root 'inputs\offline-peer-source.ps1'), '1331A36866E3B90BE7156A83AFFB65AAB1112DEC4425E9C9176399199ED91F11') - zmx = @((Join-Path $root 'runtime\zmx.exe'), 'DFFF744FA8D7B0FB483416674C2FF4C2647EA46A3BDD82A95DC173A1FA07DC5B') - before = @((Join-Path $root 'inputs\graphcode-before.exe'), '83688A62FD21CCAD36327FE6B4C3143FED25F211A6A136D0FD20CDEF4DF48420') - after = @((Join-Path $root 'inputs\graphcode-after.exe'), 'C610D91E614F9ACFFC28AF37B3C6429D978AC5EEF8B4AC492F8EB39506CA9E51') + peer = @((Join-Path $root 'inputs\offline-peer-source.ps1'), $job.fixture.offlinePeerReference.sha256) + zmx = @((Join-Path $root 'runtime\zmx.exe'), $job.binaries.offlineZmx.sha256) + before = @((Join-Path $root 'inputs\graphcode-before.exe'), $job.binaries.before.sha256) + after = @((Join-Path $root 'inputs\graphcode-after.exe'), $job.binaries.after.sha256) } foreach ($key in $inputHashes.Keys) { + $null = Assert-CapArtifactPath -Root $root -Path $inputHashes[$key][0] $actual = (Get-FileHash -Algorithm SHA256 -LiteralPath $inputHashes[$key][0]).Hash if ($actual -ne $inputHashes[$key][1]) { throw "Cap-owned $key input hash mismatch: $actual" } } - if ($job.fixture.sha256 -ne $inputHashes.fixtureSource[1] -or - $job.binaries.before.sha256 -ne $inputHashes.before[1] -or - $job.binaries.after.sha256 -ne $inputHashes.after[1] -or - $job.binaries.offlineZmx.sha256 -ne $inputHashes.zmx[1]) { - throw 'Pinned input hashes do not match the green job manifest.' - } - $sourceRoot = Get-CapSourceRoot -BinaryPath $job.binaries.after.path - $head = (& git -C $sourceRoot rev-parse HEAD).Trim() - if ($LASTEXITCODE -ne 0 -or $head -ne $job.head) { throw "B source worktree head mismatch: $head" } - $status = @(& git -C $sourceRoot status --porcelain) - if ($LASTEXITCODE -ne 0 -or $status.Count -ne 0) { throw 'B source worktree is not clean.' } - return $sourceRoot + return $jobIdentity.sourceRepository } function Assert-CapPreflightReport { @@ -222,6 +163,11 @@ function Assert-CapPreflightReport { @($preflight.cases | Where-Object passed -NE $true).Count -ne 0) { throw 'The repaired-core preflight does not prove a nonempty successful exact-source offline rehearsal.' } + $caseNames = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + foreach ($case in $preflight.cases) { + Assert-CapDataRecord $case @{ name = [string]; passed = [bool] } 'Preflight case' + if (-not $caseNames.Add($case.name)) { throw 'Duplicate preflight case identity.' } + } foreach ($required in @( 'real capture command with nonempty arguments', 'real capture command with genuine zero arguments', @@ -229,7 +175,16 @@ function Assert-CapPreflightReport { 'same capture command handles target nonzero', 'same capture command handles target timeout', 'same capture command handles target primary-cleanup', - 'same capture command handles target receipt-block' + 'same capture command handles target receipt-block', + 'lease-bound admission accepted through actual capture command', + 'lease-bound admission lane-d through actual capture command', + 'lease-bound admission red-ci through actual capture command', + 'lease-bound admission binary-hash through actual capture command', + 'lease-bound admission product-diff through actual capture command', + 'lease-bound admission ci-head through actual capture command', + 'lease-bound admission empty-ci through actual capture command', + 'lease-bound admission source-bytes through actual capture command', + 'lease-bound admission lease-base through actual capture command' )) { if (@($preflight.cases | Where-Object name -CEQ $required).Count -ne 1) { throw "Repaired-core preflight is missing a unique required actual-command case: $required" @@ -343,7 +298,7 @@ function Assert-CapPreflightReport { throw 'Offline preflight did not exercise the required positive and negative UIA guard paths.' } $priorLifecyclePath = Join-Path $root 'reports\preflight-08.json' - if ((Get-FileHash -Algorithm SHA256 -LiteralPath $priorLifecyclePath).Hash -ne $preflight08HashExpected) { + if ((Get-FileHash -Algorithm SHA256 -LiteralPath $priorLifecyclePath).Hash -ne $job.admission.priorLifecycleSha256) { throw 'Preserved real no-GUI preflight-08 lifecycle evidence changed.' } $priorLifecycle = Get-Content -LiteralPath $priorLifecyclePath -Raw | ConvertFrom-Json @@ -365,17 +320,27 @@ function Assert-CapLease { $leaseHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $leasePath).Hash $script:leaseHash = $leaseHash if ($leaseHash -ne $LeaseReceiptSha256.ToUpperInvariant()) { throw 'Coordinator lease receipt hash mismatch.' } - $lease = Get-Content -LiteralPath $leasePath -Raw | ConvertFrom-Json -DateKind String + $lease = Read-CapBoundJson -Root $root -Reference ([pscustomobject]@{ path = $leasePath; sha256 = $leaseHash }) + Assert-CapDataRecord $lease @{ schemaVersion = [long]; leaseId = [string]; lane = [string] + status = [string]; authorizedAction = [string]; head = [string]; jobManifestSha256 = [string] + moduleSha256 = [string]; workerSha256 = [string]; supervisorSha256 = [string] + grantedUtc = [string]; expiresUtc = [string]; maxMinutes = [long] } 'Coordinator lease' + if ($rehearsal) { + Assert-CapDataRecord $lease @{ desktopAuthorized = [bool] } 'Rehearsal lease' + } else { + Assert-CapDataRecord $lease @{ centralPreflightAccepted = [bool]; preflightReceiptSha256 = [string] + fixtureMappingSha256 = [string] } 'Capture lease' + } $expectedStatus = if ($rehearsal) { 'REHEARSAL_ONLY' } else { 'GRANTED' } $expectedAction = if ($rehearsal) { 'no-desktop-real-launch-rehearsal' } else { 'central-cap-capture' } if ($lease.schemaVersion -ne 1 -or $lease.status -ne $expectedStatus -or - $lease.lane -ne 'B' -or $lease.authorizedAction -ne $expectedAction -or + $lease.lane -cne $job.lane -or $lease.authorizedAction -ne $expectedAction -or (-not $rehearsal -and $lease.centralPreflightAccepted -ne $true) -or ($rehearsal -and $lease.desktopAuthorized -ne $false) -or $lease.head -ne $job.head -or $lease.jobManifestSha256 -ne $jobHash -or $lease.moduleSha256 -ne $moduleHash -or $lease.workerSha256 -ne $workerHash -or $lease.supervisorSha256 -ne $supervisorHash -or $lease.preflightReceiptSha256 -ne $preflightHash) { - throw 'Coordinator lease does not bind the accepted B head and exact worker/preflight hashes.' + throw 'Coordinator lease does not bind the job lane/head and exact worker/preflight hashes.' } if (-not $rehearsal -and $lease.fixtureMappingSha256 -ne $fixtureMappingHashExpected) { throw 'Live lease does not bind the exact owned-root fixture mapping.' @@ -719,12 +684,15 @@ try { throw 'Exact green job or offline preflight receipt is missing.' } $jobHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $JobPath).Hash - if ($rehearsal) { - if (-not $JobSha256 -or $jobHash -ne $JobSha256.ToUpperInvariant()) { - throw 'Rehearsal job does not match the explicitly supplied hash.' + if ($JobSha256 -and $jobHash -ne $JobSha256.ToUpperInvariant()) { throw 'Job does not match the explicitly supplied hash.' } + $job = Read-CapBoundJson -Root $root -Reference ([pscustomobject]@{ path = $JobPath; sha256 = $jobHash }) + if (-not $rehearsal) { + if ($job.fixture.mappingSha256 -notmatch '^[A-Fa-f0-9]{64}$' -or + ($FixtureMappingSha256 -and $FixtureMappingSha256 -ne $job.fixture.mappingSha256)) { + throw 'Job fixture mapping digest is missing or differs from the supplied mapping digest.' } - } elseif ($jobHash -ne $jobHashExpected) { throw "B green job hash mismatch: $jobHash" } - $job = Get-Content -LiteralPath $JobPath -Raw | ConvertFrom-Json -DateKind String + $fixtureMappingHashExpected = $job.fixture.mappingSha256 + } if ($rehearsal) { if ($job.executionMode -cne $ExecutionMode -or $job.desktopAuthorized -ne $false -or $job.rehearsalTarget.arguments -isnot [array] -or @@ -738,11 +706,16 @@ try { $sourceRoot = $PSScriptRoot } else { $preflightHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $preflightPath).Hash - $preflight = Get-Content -LiteralPath $preflightPath -Raw | ConvertFrom-Json + $preflight = Read-CapBoundJson -Root $root -Reference ([pscustomobject]@{ path = $preflightPath; sha256 = $preflightHash }) + } + $null = Assert-CapLease + if (-not $rehearsal -or $null -ne $job.PSObject.Properties['admission']) { + $jobIdentity = Assert-CapJobIdentity -Root $root -Job $job -Lease $lease -JobSha256 $jobHash -OfflineTestData:$rehearsal + } + if (-not $rehearsal) { $sourceRoot = Assert-CapGreenJob $null = Assert-CapPreflightReport } - $null = Assert-CapLease $exports = @(Get-Command -Module UiCaptureDriver | Sort-Object Name) if ($exports.Count -lt 30) { throw "Capture driver exports unavailable: count=$($exports.Count)." } if (-not $rehearsal) { @@ -786,6 +759,7 @@ try { preflightReceiptSha256 = $preflightHash fixtureMappingSha256 = $fixtureMappingHashExpected sourceRoot = $sourceRoot + jobIdentity = $jobIdentity runDirectory = $runDirectory outputDirectory = $outputDirectory processJournalPath = $processJournalPath diff --git a/Tools/windows/Invoke-UiCaptureWorker.ps1 b/Tools/windows/Invoke-UiCaptureWorker.ps1 index c06c5060..a3a4874f 100644 --- a/Tools/windows/Invoke-UiCaptureWorker.ps1 +++ b/Tools/windows/Invoke-UiCaptureWorker.ps1 @@ -123,10 +123,7 @@ function Write-CapProcessJournal { function Assert-CapAcceptedInputs { Write-CapTrace -Command $MyInvocation.MyCommand.Name -Parameters $PSBoundParameters - if ($job.lane -ne 'B' -or $job.head -ne 'e3ff22f278524645e4cae2690e1ff5c1aee7d66e' -or - $job.base -ne '6916bc2c2baabc2f83c92230aeee8b49e879758e') { - throw 'The copied B job does not name the accepted exact head/base.' - } + Assert-CapSceneContract -Job $job if ($job.authority.centralCaptureLeaseGrantedByThisArtifact -ne $false -or $lease.status -ne 'GRANTED' -or $lease.lane -ne 'B' -or $lease.authorizedAction -ne 'central-cap-capture' -or @@ -139,47 +136,29 @@ function Assert-CapAcceptedInputs { $lease.preflightReceiptSha256 -ne $auth.preflightReceiptSha256) { throw 'Lease receipt hashes do not bind the exact reviewed B job and capture controls.' } - $ci = [string]$job.freshness.ci - $review = [string]$job.freshness.narrowRebaseReview - $runner = [string]$job.freshness.focusedRunner - if ($ci -notmatch '24 completed contexts,20SUCCESS/4configured SKIPPED' -or - $ci -notmatch 'seven successful workflows' -or - $review -notmatch 'ACCEPT' -or $runner -notmatch '166G1executed/166passed/0failed') { - throw 'The copied job does not carry the exact-head green CI, focused-runner, and accepted narrow-review evidence.' - } - $equivalenceHash = (Get-FileHash -Algorithm SHA256 -LiteralPath (Join-Path $root 'inputs\g1-rebase-equivalence.json')).Hash $mappingProof = Assert-CapFixtureMapping -Root $root -FixturePath $fixturePath ` -SourcePath (Join-Path $root 'inputs\fixture-source.json') -MappingPath (Join-Path $root 'reports\fixture-mapping.json') ` -ExpectedMappingSha256 $auth.fixtureMappingSha256 ` - -ExpectedSourceSha256 'BFC26149FCC5AD2DE4C042E1E4F200F8C2299B662E24DA426EDB3683AA4C56FA' ` - -OriginalRoot 'C:\gcs\ib\captures' + -ExpectedSourceSha256 $job.fixture.sha256 -OriginalRoot $job.fixture.originalRoot $expectedHashes = @{ fixture = $mappingProof.FixtureSha256 - fixtureSource = 'BFC26149FCC5AD2DE4C042E1E4F200F8C2299B662E24DA426EDB3683AA4C56FA' + fixtureSource = $job.fixture.sha256 fixtureMapping = $auth.fixtureMappingSha256 - peer = '1331A36866E3B90BE7156A83AFFB65AAB1112DEC4425E9C9176399199ED91F11' - zmx = 'DFFF744FA8D7B0FB483416674C2FF4C2647EA46A3BDD82A95DC173A1FA07DC5B' - before = '83688A62FD21CCAD36327FE6B4C3143FED25F211A6A136D0FD20CDEF4DF48420' - after = 'C610D91E614F9ACFFC28AF37B3C6429D978AC5EEF8B4AC492F8EB39506CA9E51' - } - $actual = @{ - fixture = (Get-FileHash -Algorithm SHA256 -LiteralPath $fixturePath).Hash - fixtureSource = (Get-FileHash -Algorithm SHA256 -LiteralPath (Join-Path $root 'inputs\fixture-source.json')).Hash - fixtureMapping = (Get-FileHash -Algorithm SHA256 -LiteralPath (Join-Path $root 'reports\fixture-mapping.json')).Hash - peer = (Get-FileHash -Algorithm SHA256 -LiteralPath $peerScript).Hash - zmx = (Get-FileHash -Algorithm SHA256 -LiteralPath (Join-Path $root 'runtime\zmx.exe')).Hash - before = (Get-FileHash -Algorithm SHA256 -LiteralPath (Join-Path $root 'inputs\graphcode-before.exe')).Hash - after = (Get-FileHash -Algorithm SHA256 -LiteralPath (Join-Path $root 'inputs\graphcode-after.exe')).Hash - } - if ($equivalenceHash -ne '0D3C236B736DC38DCFDB36166F7593313F10A27A716DAD39664EFFC329F3B8CA' -or - $job.fixture.sha256 -ne $expectedHashes.fixtureSource -or - $job.fixture.offlinePeerReference.sha256 -ne '1331A36866E3B90BE7156A83AFFB65AAB1112DEC4425E9C9176399199ED91F11' -or - $job.binaries.offlineZmx.sha256 -ne $expectedHashes.zmx -or - $job.binaries.before.sha256 -ne $expectedHashes.before -or - $job.binaries.after.sha256 -ne $expectedHashes.after) { - throw 'Job pins and cap-owned input provenance do not agree.' - } + peer = $job.fixture.offlinePeerReference.sha256 + zmx = $job.binaries.offlineZmx.sha256 + before = $job.binaries.before.sha256 + after = $job.binaries.after.sha256 + } + $paths = @{ + fixture = $fixturePath; fixtureSource = (Join-Path $root 'inputs\fixture-source.json') + fixtureMapping = (Join-Path $root 'reports\fixture-mapping.json'); peer = $peerScript + zmx = (Join-Path $root 'runtime\zmx.exe'); before = (Join-Path $root 'inputs\graphcode-before.exe') + after = (Join-Path $root 'inputs\graphcode-after.exe') + } + $actual = @{} foreach ($key in $expectedHashes.Keys) { + $path = Assert-CapArtifactPath -Root $root -Path $paths[$key] + $actual[$key] = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash if ($actual[$key] -ne $expectedHashes[$key]) { throw "Cap-owned $key input hash changed: $($actual[$key])." } } $script:fixtureSourceHash = $actual.fixtureSource @@ -1036,8 +1015,8 @@ try { if ($AuthorizationPath -ine $auth.authorizationPath) { throw 'Worker command-line authorization does not match the supervisor-bound record.' } - $job = Get-Content -LiteralPath $jobPath -Raw | ConvertFrom-Json - $lease = Get-Content -LiteralPath $leasePath -Raw | ConvertFrom-Json -DateKind String + $job = Read-CapBoundJson -Root $root -Reference ([pscustomobject]@{ path = $jobPath; sha256 = $jobHash }) + $lease = Read-CapBoundJson -Root $root -Reference ([pscustomobject]@{ path = $leasePath; sha256 = $leaseHash }) if (-not $rehearsal -and $lease.fixtureMappingSha256 -ne $auth.fixtureMappingSha256) { throw 'Worker lease does not bind its exact owned-root fixture mapping.' } @@ -1062,6 +1041,12 @@ try { if ($supervisorIdentity.CreationFileTimeUtc -ne [long]$auth.supervisorCreationFileTimeUtc) { throw 'Supervisor kernel generation differs from the run authorization.' } + if (-not $rehearsal -or $null -ne $job.PSObject.Properties['admission']) { + $identity = Assert-CapJobIdentity -Root $root -Job $job -Lease $lease -JobSha256 $jobHash -OfflineTestData:$rehearsal + if (($identity | ConvertTo-Json -Depth 15 -Compress) -cne ($auth.jobIdentity | ConvertTo-Json -Depth 15 -Compress)) { + throw 'Worker recomputed job identity differs from supervisor authorization.' + } + } if ($rehearsal) { if ($lease.status -cne 'REHEARSAL_ONLY' -or $lease.desktopAuthorized -ne $false -or $lease.authorizedAction -cne 'no-desktop-real-launch-rehearsal' -or @@ -1074,13 +1059,6 @@ try { } } else { Assert-CapAcceptedInputs - $sourceRoot = [IO.Path]::GetFullPath($job.binaries.after.path) - for ($parentDepth = 0; $parentDepth -lt 4; $parentDepth++) { $sourceRoot = Split-Path -Parent $sourceRoot } - $sourceHead = (& git -C $sourceRoot rev-parse HEAD).Trim() - if ($LASTEXITCODE -ne 0 -or $sourceHead -ne $job.head) { throw "B source head changed after grant: $sourceHead" } - $sourceStatus = @(& git -C $sourceRoot status --porcelain) - if ($LASTEXITCODE -ne 0 -or $sourceStatus.Count -ne 0) { throw 'B source worktree is no longer clean.' } - $zmxHash = (Get-FileHash -Algorithm SHA256 -LiteralPath (Join-Path $root 'runtime\zmx.exe')).Hash $beforeHash = (Get-FileHash -Algorithm SHA256 -LiteralPath (Join-Path $root 'inputs\graphcode-before.exe')).Hash $afterHash = (Get-FileHash -Algorithm SHA256 -LiteralPath (Join-Path $root 'inputs\graphcode-after.exe')).Hash diff --git a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 index dff59c81..14af78b9 100644 --- a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 +++ b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 @@ -159,7 +159,8 @@ function Invoke-CapCaptureCommand { [string]$Name, [AllowEmptyCollection()][AllowEmptyString()][string[]]$Arguments = @(), [string]$Behavior = 'normal', - [string]$LeaseMutation = '' + [string]$LeaseMutation = '', + [string]$AdmissionMutation = '' ) $owned = Join-Path $scratch "cases\$Name" $null = New-Item -ItemType Directory -Path $owned @@ -174,6 +175,7 @@ function Invoke-CapCaptureCommand { rehearsalTarget = [ordered]@{ path = $exe; sha256 = $target.executableSha256 arguments = @($Arguments); behavior = $Behavior; timeoutMilliseconds = 1000 } } + if ($AdmissionMutation) { $job = New-CapAdmissionJob -Owned $owned -Job $job -Mutation $AdmissionMutation } $job | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $jobPath -Encoding utf8NoBOM $jobHash = (Get-FileHash -LiteralPath $jobPath -Algorithm SHA256).Hash $now = [DateTime]::UtcNow @@ -185,6 +187,20 @@ function Invoke-CapCaptureCommand { supervisorSha256 = $sourceHashesBefore[$supervisorPath]; preflightReceiptSha256 = $null grantedUtc = $now.AddSeconds(-1).ToString('o'); expiresUtc = $now.AddMinutes(5).ToString('o'); maxMinutes = 15 } + if ($AdmissionMutation) { + $lease.lane = $job.lane + $lease.base = $job.base + $lease.reviewedHead = $job.admission.reviewedHead + $lease.reviewAcceptanceSha256 = $job.admission.reviewAcceptance.sha256 + $lease.equivalenceSha256 = $job.admission.equivalence.sha256 + $lease.ciInventorySha256 = $job.admission.ci.sha256 + $lease.ciRuns = $job.admission.ci.runs + foreach ($phase in @('before', 'after')) { + $lease["${phase}BuildReceiptSha256"] = $job.binaries[$phase].buildReceipt.sha256 + $lease["${phase}BinarySha256"] = $job.binaries[$phase].sha256 + } + if ($AdmissionMutation -eq 'lease-base') { $lease.base = '0' * 40 } + } switch ($LeaseMutation) { 'expired' { $lease.grantedUtc = $now.AddMinutes(-6).ToString('o'); $lease.expiresUtc = $now.AddSeconds(-1).ToString('o') } 'future' { $lease.grantedUtc = $now.AddMinutes(1).ToString('o') } @@ -218,6 +234,131 @@ function Invoke-CapCaptureCommand { } } +function Write-CapTestArtifact { + param([string]$Path, $Value) + $Value | ConvertTo-Json -Depth 30 | Set-Content -LiteralPath $Path -Encoding utf8NoBOM + return [ordered]@{ path = $Path; sha256 = (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash } +} + +function Invoke-CapTestGit { + param([string]$Repository, [string[]]$Arguments) + $output = @(& git -C $Repository @Arguments 2>&1) + if ($LASTEXITCODE -ne 0) { throw "Offline fixture Git failed: $($output -join [Environment]::NewLine)" } + return $output +} + +function New-CapAdmissionJob { + param([string]$Owned, $Job, [string]$Mutation) + $repository = Join-Path $Owned 'source-repository' + $null = New-Item -ItemType Directory -Path $repository + $null = Invoke-CapTestGit $repository @('init', '--quiet') + $null = Invoke-CapTestGit $repository @('config', 'user.name', 'Offline capture fixture') + $null = Invoke-CapTestGit $repository @('config', 'user.email', 'fixture@example.invalid') + $null = Invoke-CapTestGit $repository @('config', 'core.autocrlf', 'false') + $null = New-Item -ItemType Directory -Path (Join-Path $repository 'graphcode-windows') + [IO.File]::WriteAllText((Join-Path $repository 'graphcode-windows\product.txt'), 'before') + $null = Invoke-CapTestGit $repository @('add', '.') + $null = Invoke-CapTestGit $repository @('commit', '--quiet', '-m', 'Offline before') + $base = [string](Invoke-CapTestGit $repository @('rev-parse', 'HEAD')) + [IO.File]::WriteAllText((Join-Path $repository 'graphcode-windows\product.txt'), 'reviewed after') + $null = Invoke-CapTestGit $repository @('commit', '--quiet', '-am', 'Offline reviewed product') + $reviewed = [string](Invoke-CapTestGit $repository @('rev-parse', 'HEAD')) + $null = New-Item -ItemType Directory -Path (Join-Path $repository 'Tools\windows') -Force + [IO.File]::WriteAllText((Join-Path $repository 'Tools\windows\Invoke-UiCapture.ps1'), '# Offline tooling-only delta') + if ($Mutation -eq 'product-diff') { + [IO.File]::WriteAllText((Join-Path $repository 'graphcode-windows\product.txt'), 'UNREVIEWED product') + } + $null = Invoke-CapTestGit $repository @('add', '.') + $null = Invoke-CapTestGit $repository @('commit', '--quiet', '-m', 'Offline candidate') + $head = [string](Invoke-CapTestGit $repository @('rev-parse', 'HEAD')) + $Job.head = $head + $Job.base = $base + if ($Mutation -eq 'lane-d') { $Job.lane = 'D' } + $Job.sourceRepository = $repository + $Job.binaries = [ordered]@{} + $buildInput = Join-Path $Owned 'build-input.c' + Copy-Item -LiteralPath $target.source -Destination $buildInput + foreach ($phase in @('before', 'after')) { + $commit = if ($phase -eq 'before') { $base } else { $head } + $archive = Join-Path $Owned "$phase.zip" + $sourceRoot = Join-Path $Owned "source-$phase" + $null = Invoke-CapTestGit $repository @('archive', '--format=zip', "--output=$archive", $commit) + Expand-Archive -LiteralPath $archive -DestinationPath $sourceRoot + $logPath = Join-Path $Owned "$phase-build.log" + [IO.File]::WriteAllText($logPath, 'OFFLINE_TEST_DATA: no product build was performed.') + $binary = Join-Path $Owned "$phase.exe" + Copy-Item -LiteralPath $target.executable -Destination $binary + $receipt = [ordered]@{ + schemaVersion = 1; evidenceMode = 'OFFLINE_TEST_DATA_NOT_PRODUCT_BUILD' + sourceCommit = $commit; sourceTree = [string](Invoke-CapTestGit $repository @('rev-parse', "${commit}:graphcode-windows")) + sourceRoot = Join-Path $sourceRoot 'graphcode-windows'; sourceSubdirectory = 'graphcode-windows' + binaryBuiltAtSource = $commit + archive = [ordered]@{ path = $archive; sha256 = (Get-FileHash $archive -Algorithm SHA256).Hash } + log = [ordered]@{ path = $logPath; sha256 = (Get-FileHash $logPath -Algorithm SHA256).Hash } + executablePath = $binary; executableSha256 = $target.executableSha256 + startedUtc = [DateTime]::UtcNow.AddSeconds(-5).ToString('o') + finishedUtc = [DateTime]::UtcNow.AddSeconds(-1).ToString('o') + exitCode = 0; command = 'OFFLINE_TEST_DATA: existing harmless fixture, not a product build' + inputs = @([ordered]@{ path = $buildInput; sha256 = $target.sourceSha256 }) + } + $receipt.rawReceipt = Write-CapTestArtifact (Join-Path $Owned "$phase-raw-build.json") ([ordered]@{ + sourceSha = $commit; builtAtSha = $commit; sourceTree = $receipt.sourceTree + sourceExportSha256 = $receipt.archive.sha256; executableSha256 = $receipt.executableSha256 + exitCode = 0; startedUtc = $receipt.startedUtc; finishedUtc = $receipt.finishedUtc; command = $receipt.command + }) + if ($phase -eq 'after' -and $Mutation -eq 'null-build') { $receipt.sourceCommit = $null } + if ($phase -eq 'after' -and $Mutation -eq 'stale-build') { $receipt.binaryBuiltAtSource = $base } + $Job.binaries[$phase] = [ordered]@{ + path = $binary; sha256 = $target.executableSha256; source = $commit; binaryBuiltAtSource = $commit + buildReceipt = Write-CapTestArtifact (Join-Path $Owned "$phase-build.json") $receipt + } + } + $ci = [ordered]@{ + schemaVersion = 1; evidenceMode = 'OFFLINE_TEST_DATA_NOT_LIVE_CI' + repository = 'scgopi/GraphCode'; head = $head + observedUtc = [DateTime]::UtcNow.ToString('o') + runs = @([ordered]@{ + id = 123; run_attempt = 1; head_sha = $head; status = 'completed'; conclusion = 'success' + total_count = 1; jobs = @([ordered]@{ + id = 456; run_id = 123; head_sha = $head; name = 'Offline synthetic context' + status = 'completed'; conclusion = 'success'; configuredSkip = $false + }) + }) + } + if ($Mutation -eq 'red-ci') { + $ci.runs[0].conclusion = 'failure' + $ci.runs[0].jobs[0].conclusion = 'failure' + } + if ($Mutation -eq 'ci-head') { $ci.runs[0].jobs[0].head_sha = $base } + if ($Mutation -eq 'empty-ci') { $ci.runs[0].jobs = @(); $ci.runs[0].total_count = 0 } + if ($Mutation -eq 'duplicate-ci') { + $ci.runs[0].jobs = @($ci.runs[0].jobs[0], $ci.runs[0].jobs[0]) + $ci.runs[0].total_count = 2 + } + if ($Mutation -eq 'source-bytes') { [IO.File]::AppendAllText((Join-Path $Owned 'source-after\graphcode-windows\product.txt'), 'DIRTY') } + if ($Mutation -eq 'binary-hash') { [IO.File]::AppendAllText($Job.binaries.after.path, 'MISMATCH') } + $Job.admission = [ordered]@{ + schemaVersion = 1; reviewedHead = $reviewed + reviewAcceptance = Write-CapTestArtifact (Join-Path $Owned 'review.json') ([ordered]@{ + schemaVersion = 1; verdict = 'ACCEPT'; reviewedHead = $reviewed; reviewedBase = $base + evidenceMode = 'OFFLINE_TEST_DATA_NOT_REVIEW' + }) + equivalence = Write-CapTestArtifact (Join-Path $Owned 'equivalence.json') ([ordered]@{ + schemaVersion = 1; reviewedHead = $reviewed; head = $head; reviewedBase = $base; base = $base + headDeltaPaths = @(Invoke-CapTestGit $repository @('diff', '--name-only', '--no-renames', $reviewed, $head)) + baseDeltaPaths = @() + }) + ci = Write-CapTestArtifact (Join-Path $Owned 'ci.json') $ci + } + $Job.admission.ci.runs = @([ordered]@{ id = 123; attempt = 1 }) + if ($Mutation -eq 'duplicate-json') { + $path = $Job.admission.ci.path + [IO.File]::WriteAllText($path, [IO.File]::ReadAllText($path).Insert(1, "`"head`":`"$head`",")) + $Job.admission.ci.sha256 = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash + } + return $Job +} + function Assert-CapRehearsalSuccess { param($Run, [AllowEmptyCollection()][AllowEmptyString()][string[]]$Arguments) Assert-CapTest ($Run.child.exitCode -eq 0 -and -not $Run.child.stdinRedirected) "Capture command failed or changed default stdin inheritance: $($Run.child.stderr)" @@ -252,6 +393,56 @@ function Assert-CapRehearsalSuccess { $target = $null try { $target = New-CapHarmlessTarget + if (-not $LaunchContractOnly) { + foreach ($mutation in @('accepted', 'lane-d', 'red-ci', 'binary-hash', 'product-diff', + 'ci-head', 'empty-ci', 'source-bytes', 'lease-base', 'duplicate-ci', 'duplicate-json', 'null-build', 'stale-build')) { + Invoke-CapTest "lease-bound admission $mutation through actual capture command" { + $run = Invoke-CapCaptureCommand -Name "admission-$mutation" -AdmissionMutation $mutation + if ($mutation -in @('accepted', 'lane-d')) { + $null = Assert-CapRehearsalSuccess -Run $run -Arguments @() + foreach ($component in @('supervisor', 'worker')) { + $trace = @(Get-Content (Join-Path $run.output "$component-calls.jsonl") | + ForEach-Object { $_ | ConvertFrom-Json }) + Assert-CapTest (@($trace | Where-Object command -CEQ 'Assert-CapJobIdentity').Count -eq 1) ` + "Lease-bound identity was not checked by the actual $component." + } + } else { + $expected = switch ($mutation) { + 'red-ci' { 'CI run is not exact-head completed success' } + 'binary-hash' { 'binary hash mismatch' } + 'product-diff' { 'Non-equivalent product/provider/build input diff' } + 'ci-head' { 'CI context is not exact-head green' } + 'empty-ci' { 'CI inventory must contain a positive complete context count' } + 'source-bytes' { 'Fresh source bytes differ' } + 'lease-base' { 'Lease-bound job identity differs' } + 'duplicate-ci' { 'CI context is not exact-head green' } + 'duplicate-json' { 'Duplicate JSON field' } + 'null-build' { "field 'sourceCommit'" } + 'stale-build' { 'Fresh build provenance must name the exact phase source' } + } + Assert-CapTest ($run.child.exitCode -ne 0 -and $null -ne $run.receipt -and + $run.receipt.status -ceq 'REFUSED_NO_WORKER' -and -not $run.receipt.workerStarted -and + $run.receipt.primaryFailure.Contains($expected) -and + $run.receipt.leaseReceiptSha256 -ceq $run.leaseSha256 -and + $run.receipt.cleanupState -ceq 'NO_WORKER_STARTED') ` + "Admission $mutation was not refused by the actual supervisor with its official release: $($run.child.stderr)" + } + return [ordered]@{ release = $run.receiptPath; sha256 = $run.receiptSha256 + status = $run.receipt.status; exit = $run.child.exitCode + data = 'Synthetic CI/build/review controls; real Git, binaries, command, lease, worker and release; no product evidence' } + } + } + } + if (-not $LaunchContractOnly) { + Invoke-CapTest 'unsupported C D E scene adapters refuse independently of generic identity' { + foreach ($lane in @('C', 'D', 'E')) { + $null = Assert-CapRejects { + Assert-CapSceneContract -Job ([pscustomobject]@{ lane = $lane; sceneAdapter = 'B_FIVE_SCENES_V1' }) + } 'Unsupported scene adapter' + } + return 'Pure scene-contract refusal; no scene, UI, product or adapter ran.' + } + } Invoke-CapTest 'real capture command with nonempty arguments' { $arguments = [string[]]@('alpha', 'two words', 'quote"inside', 'trailing\') $run = Invoke-CapCaptureCommand -Name 'nonempty' -Arguments $arguments diff --git a/Tools/windows/UiCaptureDriver.psm1 b/Tools/windows/UiCaptureDriver.psm1 index 81e225e6..997b0264 100644 --- a/Tools/windows/UiCaptureDriver.psm1 +++ b/Tools/windows/UiCaptureDriver.psm1 @@ -1549,6 +1549,316 @@ function Test-CapFixtureIsolation { } } +function Assert-CapDataRecord { + param([Parameter(Mandatory)]$Value, [Parameter(Mandatory)][hashtable]$Fields, + [Parameter(Mandatory)][string]$Label) + if ($Value -isnot [pscustomobject]) { throw "$Label must be a complete typed object." } + foreach ($name in $Fields.Keys) { + $property = $Value.PSObject.Properties[$name] + if ($null -eq $property -or $null -eq $property.Value -or + -not $Fields[$name].IsInstanceOfType($property.Value) -or + ($property.Value -is [string] -and [string]::IsNullOrWhiteSpace($property.Value))) { + throw "$Label requires a non-null $($Fields[$name].Name) field '$name'." + } + } +} + +function Assert-CapSceneContract { + param([Parameter(Mandatory)]$Job) + if ($Job.lane -cne 'B' -or $Job.sceneAdapter -cne 'B_FIVE_SCENES_V1') { + throw 'Unsupported scene adapter; generic identity does not implement C/D/E scene execution.' + } + Assert-CapDataRecord $Job @{ authority = [pscustomobject]; captureContract = [pscustomobject] + scenes = [array]; fixture = [pscustomobject] } 'B scene job' + Assert-CapDataRecord $Job.authority @{ centralCaptureLeaseGrantedByThisArtifact = [bool] + bWorkerMayLaunchGui = [bool] } 'Job non-authority' + Assert-CapDataRecord $Job.captureContract @{ phases = [array]; sceneCountPerPhase = [long] + windowOuterXYWH = [array]; expectedClientWH = [array]; dpi = [long]; canvasZoomPercent = [long] } 'B geometry' + Assert-CapDataRecord $Job.fixture @{ sha256 = [string]; mappingSha256 = [string] + originalRoot = [string]; offlinePeerReference = [pscustomobject] } 'B fixture' + Assert-CapDataRecord $Job.fixture.offlinePeerReference @{ sha256 = [string] } 'Offline peer' + Assert-CapDataRecord $Job.binaries.offlineZmx @{ sha256 = [string]; seedArgumentOrder = [array] } 'Offline seed' + if ($Job.authority.centralCaptureLeaseGrantedByThisArtifact -or $Job.authority.bWorkerMayLaunchGui -or + ($Job.captureContract.phases -join ',') -cne 'before,after' -or + $Job.captureContract.sceneCountPerPhase -ne 5 -or + ($Job.captureContract.windowOuterXYWH -join ',') -cne '30,30,1456,959' -or + ($Job.captureContract.expectedClientWH -join ',') -cne '1440,900' -or + $Job.captureContract.dpi -ne 96 -or $Job.captureContract.canvasZoomPercent -ne 100 -or + $Job.scenes.Count -ne 5 -or + (($Job.scenes | ForEach-Object name) -join ',') -cne 'project-alpha,project-beta,overview,workspace,actions') { + throw 'B scene, geometry, zoom or non-authority contract differs from the implemented adapter.' + } + $seed = @('run', 'graphcode-11111111-1111-4111-8111-000000000001', '-d', + 'cmd.exe', '/Q', '/K', 'echo Offline card fixture - no backend') + if (($Job.binaries.offlineZmx.seedArgumentOrder -join [char]0) -cne ($seed -join [char]0)) { + throw 'B scene adapter requires the exact offline cmd-only seed vector.' + } + foreach ($digest in @($Job.fixture.sha256, $Job.fixture.mappingSha256, + $Job.fixture.offlinePeerReference.sha256, $Job.binaries.offlineZmx.sha256)) { + if ($digest -notmatch '^[A-Fa-f0-9]{64}$') { throw 'Scene input SHA256 digest is missing or invalid.' } + } +} + +function Read-CapBoundJson { + param([Parameter(Mandatory)][string]$Root, [Parameter(Mandatory)]$Reference) + Assert-CapDataRecord $Reference @{ path = [string]; sha256 = [string] } 'Artifact reference' + $path = Assert-CapArtifactPath -Root $Root -Path $Reference.path + if ($Reference.sha256 -notmatch '^[A-Fa-f0-9]{64}$' -or + (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash -ne $Reference.sha256) { + throw "Bound artifact hash mismatch: $path" + } + $json = [IO.File]::ReadAllText($path) + $document = [Text.Json.JsonDocument]::Parse($json) + try { + $visit = { + param([Text.Json.JsonElement]$Element) + if ($Element.ValueKind -eq [Text.Json.JsonValueKind]::Object) { + $names = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach ($property in $Element.EnumerateObject()) { + if (-not $names.Add($property.Name)) { throw "Duplicate JSON field in bound artifact: $($property.Name)" } + & $visit $property.Value + } + } elseif ($Element.ValueKind -eq [Text.Json.JsonValueKind]::Array) { + foreach ($item in $Element.EnumerateArray()) { & $visit $item } + } + } + & $visit $document.RootElement + } finally { $document.Dispose() } + return ConvertFrom-Json -InputObject $json -DateKind String +} + +function Invoke-CapAdmissionGit { + param([Parameter(Mandatory)][string]$Repository, [Parameter(Mandatory)][string[]]$Arguments) + $output = @(& git --no-pager -c core.quotepath=false -C $Repository @Arguments 2>&1) + if ($LASTEXITCODE -ne 0) { throw "Source identity Git command failed: $($output -join [Environment]::NewLine)" } + return $output +} + +function Assert-CapSourceArchive { + param([Parameter(Mandatory)][string]$Root, [Parameter(Mandatory)][string]$Repository, + [Parameter(Mandatory)][string]$Commit, [Parameter(Mandatory)]$Receipt) + $sourceRoot = Assert-CapArtifactPath -Root $Root -Path $Receipt.sourceRoot + # This is archive byte validation, not a claim that compilation was observed by this process. + $treeSpec = if ($Receipt.sourceSubdirectory -ceq '.') { "$Commit`^{tree}" } + elseif ($Receipt.sourceSubdirectory -ceq 'graphcode-windows') { "${Commit}:graphcode-windows" } + else { throw 'Fresh source coverage must be the full repository or graphcode-windows, not a narrowed input subset.' } + $tree = [string](Invoke-CapAdmissionGit $Repository @('rev-parse', '--verify', $treeSpec)) + if ($tree -cne $Receipt.sourceTree) { throw 'Fresh source tree does not match the named Git object.' } + $entries = @(Invoke-CapAdmissionGit $Repository @('ls-tree', '-r', $tree)) + $expected = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + $gitlinks = [Collections.Generic.List[string]]::new() + foreach ($entry in $entries) { + if ($entry -notmatch '^(100644|100755|160000) (blob|commit) ([a-f0-9]{40})\t([^\r\n\t"]+)$') { + throw "Unsupported source archive entry: $entry" + } + $mode, $kind, $blob, $relative = $Matches[1], $Matches[2], $Matches[3], $Matches[4] + if ($mode -eq '160000' -and $kind -eq 'commit') { $gitlinks.Add("$relative=$blob"); continue } + if ($kind -cne 'blob' -or -not $expected.Add($relative)) { throw 'Invalid or duplicate source archive path.' } + $file = Assert-CapArtifactPath -Root $Root -Path (Join-Path $sourceRoot $relative.Replace('/', '\')) + $stream = [IO.File]::OpenRead($file) + $hash = [Security.Cryptography.IncrementalHash]::CreateHash([Security.Cryptography.HashAlgorithmName]::SHA1) + try { + $hash.AppendData([Text.Encoding]::UTF8.GetBytes("blob $($stream.Length)`0")) + $buffer = [byte[]]::new(65536) + while (($read = $stream.Read($buffer, 0, $buffer.Length)) -gt 0) { $hash.AppendData($buffer, 0, $read) } + $actual = [Convert]::ToHexString($hash.GetHashAndReset()).ToLowerInvariant() + } finally { $hash.Dispose(); $stream.Dispose() } + if ($actual -cne $blob) { throw "Fresh source bytes differ from the named Git object: $relative" } + } + $files = @(Get-ChildItem -LiteralPath $sourceRoot -Recurse -Force -File) + if ($expected.Count -le 0 -or $files.Count -ne $expected.Count) { throw 'Fresh source archive has missing or additional files.' } + foreach ($file in $files) { + $relative = [IO.Path]::GetRelativePath($sourceRoot, $file.FullName).Replace('\', '/') + if (-not $expected.Contains($relative)) { throw "Untracked fresh source input: $relative" } + } + return [pscustomobject]@{ tree = $tree; files = $expected.Count; gitlinks = @($gitlinks) } +} + +function Assert-CapJobIdentity { + [CmdletBinding()] + param([Parameter(Mandatory)][string]$Root, [Parameter(Mandatory)]$Job, + [Parameter(Mandatory)]$Lease, [Parameter(Mandatory)][string]$JobSha256, + [switch]$OfflineTestData) + Write-CapTrace -Command $MyInvocation.MyCommand.Name -Parameters $PSBoundParameters + Assert-CapDataRecord $Job @{ lane = [string]; head = [string]; base = [string] + sourceRepository = [string]; admission = [pscustomobject]; binaries = [pscustomobject] } 'Job identity' + Assert-CapDataRecord $Job.admission @{ schemaVersion = [long]; reviewedHead = [string] + reviewAcceptance = [pscustomobject]; equivalence = [pscustomobject]; ci = [pscustomobject] } 'Job admission' + Assert-CapDataRecord $Lease @{ lane = [string]; head = [string]; base = [string]; jobManifestSha256 = [string] + reviewedHead = [string]; reviewAcceptanceSha256 = [string]; equivalenceSha256 = [string] + ciInventorySha256 = [string]; ciRuns = [array] + beforeBuildReceiptSha256 = [string]; afterBuildReceiptSha256 = [string] + beforeBinarySha256 = [string]; afterBinarySha256 = [string] } 'Lease identity' + if ($Job.admission.schemaVersion -ne 1 -or $Job.lane -cnotmatch '^[A-Z]$' -or + $Lease.lane -cne $Job.lane -or $Lease.head -cne $Job.head -or $Lease.base -cne $Job.base -or + $Lease.jobManifestSha256 -ne $JobSha256 -or $Lease.reviewedHead -cne $Job.admission.reviewedHead -or + $Lease.reviewAcceptanceSha256 -ne $Job.admission.reviewAcceptance.sha256 -or + $Lease.equivalenceSha256 -ne $Job.admission.equivalence.sha256 -or + $Lease.ciInventorySha256 -ne $Job.admission.ci.sha256) { + throw 'Lease-bound job identity differs from the separately supplied coordinator lease.' + } + $repository = [IO.Path]::GetFullPath($Job.sourceRepository) + if (-not [IO.Path]::IsPathFullyQualified($Job.sourceRepository) -or + $Job.sourceRepository -notmatch '^[A-Za-z]:\\' -or $Job.sourceRepository.Substring(2).Contains(':') -or + $Job.sourceRepository -match '(^|\\)\.\.?($|\\)') { throw 'Source repository must be an explicit safe local path.' } + $ancestor = $repository + while ($ancestor) { + if ((Get-Item -LiteralPath $ancestor -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) { + throw 'Source repository has a reparse-point ancestor.' + } + $ancestor = Split-Path -Parent $ancestor + } + if ([string](Invoke-CapAdmissionGit $repository @('rev-parse', '--show-object-format')) -cne 'sha1') { + throw 'Unsupported Git source object format.' + } + foreach ($commit in @($Job.head, $Job.base, $Job.admission.reviewedHead)) { + if ($commit -cnotmatch '^[a-f0-9]{40}$' -or + [string](Invoke-CapAdmissionGit $repository @('rev-parse', '--verify', "$commit`^{commit}")) -cne $commit) { + throw 'Named source head/base/reviewed head is not an existing exact Git commit.' + } + } + if ([string](Invoke-CapAdmissionGit $repository @('rev-parse', 'HEAD')) -cne $Job.head -or + @(Invoke-CapAdmissionGit $repository @('status', '--porcelain', '--untracked-files=all')).Count -ne 0) { + throw 'Named source repository is not clean at the exact job head.' + } + $review = Read-CapBoundJson $Root $Job.admission.reviewAcceptance + $equivalence = Read-CapBoundJson $Root $Job.admission.equivalence + Assert-CapDataRecord $review @{ schemaVersion = [long]; verdict = [string]; evidenceMode = [string] + reviewedHead = [string]; reviewedBase = [string] } 'Review acceptance' + Assert-CapDataRecord $equivalence @{ schemaVersion = [long]; reviewedHead = [string]; head = [string] + reviewedBase = [string]; base = [string]; headDeltaPaths = [array]; baseDeltaPaths = [array] } 'Equivalence proof' + $reviewMode = if ($OfflineTestData) { 'OFFLINE_TEST_DATA_NOT_REVIEW' } else { 'COORDINATOR_ADMITTED_REVIEW' } + if ($review.schemaVersion -ne 1 -or $equivalence.schemaVersion -ne 1 -or + $review.verdict -cne 'ACCEPT' -or $review.evidenceMode -cne $reviewMode -or + $review.reviewedHead -cne $Job.admission.reviewedHead -or + $equivalence.reviewedHead -cne $review.reviewedHead -or $equivalence.reviewedBase -cne $review.reviewedBase -or + $equivalence.head -cne $Job.head -or $equivalence.base -cne $Job.base -or + $review.reviewedBase -cnotmatch '^[a-f0-9]{40}$') { throw 'Reviewed reference/equivalence identity is not coordinator-bound acceptance.' } + $null = Invoke-CapAdmissionGit $repository @('rev-parse', '--verify', "$($review.reviewedBase)`^{commit}") + foreach ($pair in @( + @($review.reviewedHead, $Job.head, 'headDeltaPaths'), + @($review.reviewedBase, $Job.base, 'baseDeltaPaths') + )) { + $delta = @(Invoke-CapAdmissionGit $repository @('diff', '--name-only', '--no-renames', '--no-ext-diff', + '--no-textconv', $pair[0], $pair[1], '--')) + $declared = @($equivalence.($pair[2])) + if (($delta -join "`0") -cne ($declared -join "`0") -or @($declared | Where-Object { $_ -isnot [string] }).Count) { + throw 'Equivalence proof does not name the complete exact Git delta.' + } + foreach ($path in $delta) { + # Docs and capture/test harnesses are not product build inputs; every other path is protected. + $productRoot = $path -match '^(graphcode-windows|graphcode|graphcoded|graphcode-cli|GraphcodeKit|ThirdParty|Tuist)/' + $nonProduct = -not $productRoot -and ($path -cmatch '\.md$' -or + $path -cmatch '^(windows-tests/|Tools/[^/]+/Tests/)' -or + $path -cmatch '^Tools/windows/(UiCaptureDriver\.psm1|Invoke-UiCapture(?:Worker)?\.ps1)$') + if (-not $nonProduct) { throw "Non-equivalent product/provider/build input diff: $path" } + } + } + $ci = Read-CapBoundJson $Root $Job.admission.ci + Assert-CapDataRecord $ci @{ schemaVersion = [long]; repository = [string]; head = [string] + evidenceMode = [string]; observedUtc = [string]; runs = [array] } 'CI inventory' + Assert-CapDataRecord $Job.admission.ci @{ runs = [array] } 'Job CI identity' + $ciMode = if ($OfflineTestData) { 'OFFLINE_TEST_DATA_NOT_LIVE_CI' } else { 'COORDINATOR_ADMITTED_GITHUB_API_SNAPSHOT' } + if ($ci.schemaVersion -ne 1 -or $ci.repository -cne 'scgopi/GraphCode' -or $ci.head -cne $Job.head -or + $ci.evidenceMode -cne $ciMode -or $ci.runs.Count -le 0 -or + $ci.runs.Count -ne $Job.admission.ci.runs.Count -or $ci.runs.Count -ne $Lease.ciRuns.Count -or + (ConvertTo-CapUtcInstant $ci.observedUtc 'CI observedUtc') -gt [DateTime]::UtcNow) { + throw 'CI inventory identity, mode, observation time or run count differs from the lease-bound job.' + } + $runIds = [Collections.Generic.HashSet[long]]::new() + $contextIds = [Collections.Generic.HashSet[long]]::new() + $contextCount = 0 + for ($i = 0; $i -lt $ci.runs.Count; $i++) { + $run = $ci.runs[$i] + Assert-CapDataRecord $run @{ id = [long]; run_attempt = [long]; head_sha = [string] + status = [string]; conclusion = [string]; total_count = [long]; jobs = [array] } 'CI run' + foreach ($identity in @($Job.admission.ci.runs[$i], $Lease.ciRuns[$i])) { + Assert-CapDataRecord $identity @{ id = [long]; attempt = [long] } 'Bound CI run identity' + if ($identity.id -ne $run.id -or $identity.attempt -ne $run.run_attempt) { throw 'CI run identity/attempt differs from the lease.' } + } + if ($run.id -le 0 -or $run.run_attempt -le 0 -or -not $runIds.Add($run.id) -or + $run.head_sha -cne $Job.head -or $run.status -cne 'completed' -or $run.conclusion -cne 'success') { + throw 'CI run is not exact-head completed success with a unique positive identity.' + } + if ($run.total_count -le 0 -or $run.total_count -ne $run.jobs.Count) { + throw 'CI inventory must contain a positive complete context count.' + } + foreach ($context in $run.jobs) { + Assert-CapDataRecord $context @{ id = [long]; run_id = [long]; head_sha = [string]; name = [string] + status = [string]; conclusion = [string]; configuredSkip = [bool] } 'CI context' + if ($context.id -le 0 -or -not $contextIds.Add($context.id) -or $context.run_id -ne $run.id -or + $context.head_sha -cne $Job.head -or $context.status -cne 'completed' -or + ($context.conclusion -cne 'success' -and + -not ($context.conclusion -ceq 'skipped' -and $context.configuredSkip))) { + throw 'CI context is not exact-head green or an explicitly configured skip.' + } + $contextCount++ + } + } + $builds = [ordered]@{} + foreach ($phase in @('before', 'after')) { + $binary = $Job.binaries.$phase + Assert-CapDataRecord $binary @{ path = [string]; sha256 = [string]; source = [string] + binaryBuiltAtSource = [string]; buildReceipt = [pscustomobject] } "$phase binary" + if ($Lease."${phase}BuildReceiptSha256" -ne $binary.buildReceipt.sha256 -or + $Lease."${phase}BinarySha256" -ne $binary.sha256) { throw 'Lease-bound build receipt/binary identity differs.' } + $commit = if ($phase -ceq 'before') { $Job.base } else { $Job.head } + $receipt = Read-CapBoundJson $Root $binary.buildReceipt + Assert-CapDataRecord $receipt @{ schemaVersion = [long]; evidenceMode = [string]; sourceCommit = [string] + sourceTree = [string]; sourceRoot = [string]; sourceSubdirectory = [string] + binaryBuiltAtSource = [string]; archive = [pscustomobject]; log = [pscustomobject] + rawReceipt = [pscustomobject]; executablePath = [string]; executableSha256 = [string] + startedUtc = [string]; finishedUtc = [string]; exitCode = [long]; command = [string]; inputs = [array] } "$phase fresh build receipt" + $buildMode = if ($OfflineTestData) { 'OFFLINE_TEST_DATA_NOT_PRODUCT_BUILD' } else { 'SEPARATE_FRESH_BUILD' } + if ($receipt.schemaVersion -ne 1 -or $receipt.evidenceMode -cne $buildMode -or + $binary.source -cne $commit -or $binary.binaryBuiltAtSource -cne $commit -or + $receipt.sourceCommit -cne $commit -or $receipt.binaryBuiltAtSource -cne $commit -or + $receipt.exitCode -ne 0 -or $receipt.executablePath -cne $binary.path -or + $receipt.executableSha256 -ne $binary.sha256 -or $receipt.inputs.Count -le 0) { + throw 'Fresh build provenance must name the exact phase source and actual built-at commit.' + } + $started = ConvertTo-CapUtcInstant $receipt.startedUtc 'Build startedUtc' + $finished = ConvertTo-CapUtcInstant $receipt.finishedUtc 'Build finishedUtc' + if ($finished -lt $started -or $finished -gt [DateTime]::UtcNow) { throw 'Fresh build timestamps are reversed or future-dated.' } + $binaryPath = Assert-CapArtifactPath -Root $Root -Path $binary.path + if ($binary.sha256 -notmatch '^[A-Fa-f0-9]{64}$' -or + (Get-FileHash -LiteralPath $binaryPath -Algorithm SHA256).Hash -ne $binary.sha256) { + throw "$phase binary hash mismatch against the separately produced fresh build." + } + $artifactPaths = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach ($reference in @($receipt.archive, $receipt.log) + @($receipt.inputs)) { + Assert-CapDataRecord $reference @{ path = [string]; sha256 = [string] } 'Build artifact' + $path = Assert-CapArtifactPath -Root $Root -Path $reference.path + if (-not $artifactPaths.Add($path)) { throw 'Duplicate fresh build artifact/input path.' } + if ($reference.sha256 -notmatch '^[A-Fa-f0-9]{64}$' -or + (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash -ne $reference.sha256) { + throw "Fresh build artifact hash mismatch: $path" + } + } + $raw = Read-CapBoundJson $Root $receipt.rawReceipt + Assert-CapDataRecord $raw @{ sourceSha = [string]; builtAtSha = [string]; sourceTree = [string] + sourceExportSha256 = [string]; executableSha256 = [string]; exitCode = [long] + startedUtc = [string]; finishedUtc = [string]; command = [string] } 'Original build receipt' + if ($raw.sourceSha -cne $commit -or $raw.builtAtSha -cne $commit -or $raw.sourceTree -cne $receipt.sourceTree -or + $raw.sourceExportSha256 -ne $receipt.archive.sha256 -or $raw.executableSha256 -ne $binary.sha256 -or + $raw.exitCode -ne 0 -or $raw.startedUtc -cne $receipt.startedUtc -or + $raw.finishedUtc -cne $receipt.finishedUtc -or $raw.command -cne $receipt.command) { + throw 'Original build receipt does not agree with its relocated fresh-build artifacts.' + } + $source = Assert-CapSourceArchive -Root $Root -Repository $repository -Commit $commit -Receipt $receipt + $builds[$phase] = [ordered]@{ source = $binary.source; binaryBuiltAtSource = $binary.binaryBuiltAtSource + sha256 = $binary.sha256; receiptSha256 = $binary.buildReceipt.sha256 + sourceTree = $source.tree; sourceFiles = $source.files; gitlinks = $source.gitlinks } + } + return [pscustomobject]@{ + head = $Job.head; base = $Job.base; lane = $Job.lane; reviewedHead = $Job.admission.reviewedHead + sourceRepository = $repository; ciInventorySha256 = $Job.admission.ci.sha256 + ciRuns = $ci.runs.Count; ciContexts = $contextCount + equivalenceSha256 = $Job.admission.equivalence.sha256; builds = [pscustomobject]$builds + attestation = 'LEASE_BOUND_ARTIFACT_VALIDATION_NOT_OBSERVED_COMPILATION_OR_LIVE_CI' + } +} + function ConvertTo-CapUtcInstant { param( [Parameter(Mandatory)][string]$Value, @@ -1632,6 +1942,10 @@ Export-ModuleMember -Function @( 'Get-CapTraceFailures', 'Assert-CapArtifactPath', 'Assert-CapFixtureMapping', + 'Read-CapBoundJson', + 'Assert-CapDataRecord', + 'Assert-CapJobIdentity', + 'Assert-CapSceneContract', 'Assert-CapDescendantLifetime', 'Test-CapGraphAppRootRole', 'Resolve-CapAutomationCandidate', From a5fa9275bf90389ca55f65b372ca6d2ea9c9daeb Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Wed, 14 Oct 2026 17:56:40 -0700 Subject: [PATCH 2/4] Harden immutable admission reads and reviewed-delta coverage Refs #822 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: fc358da6-6436-4c4b-8086-c4778016cd55 Signed-off-by: Colin Neilens --- Tools/windows/Invoke-UiCapture.ps1 | 5 +++- Tools/windows/Tests/UiCaptureDriver.Tests.ps1 | 23 +++++++++++++++++-- Tools/windows/UiCaptureDriver.psm1 | 11 +++++---- 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/Tools/windows/Invoke-UiCapture.ps1 b/Tools/windows/Invoke-UiCapture.ps1 index a7d0cfed..a2448e30 100644 --- a/Tools/windows/Invoke-UiCapture.ps1 +++ b/Tools/windows/Invoke-UiCapture.ps1 @@ -184,7 +184,10 @@ function Assert-CapPreflightReport { 'lease-bound admission ci-head through actual capture command', 'lease-bound admission empty-ci through actual capture command', 'lease-bound admission source-bytes through actual capture command', - 'lease-bound admission lease-base through actual capture command' + 'lease-bound admission lease-base through actual capture command', + 'lease-bound admission omitted-product-diff through actual capture command', + 'lease-bound admission phase-swap through actual capture command', + 'lease-bound admission unrelated-base through actual capture command' )) { if (@($preflight.cases | Where-Object name -CEQ $required).Count -ne 1) { throw "Repaired-core preflight is missing a unique required actual-command case: $required" diff --git a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 index 14af78b9..cfd184e7 100644 --- a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 +++ b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 @@ -265,12 +265,16 @@ function New-CapAdmissionJob { $reviewed = [string](Invoke-CapTestGit $repository @('rev-parse', 'HEAD')) $null = New-Item -ItemType Directory -Path (Join-Path $repository 'Tools\windows') -Force [IO.File]::WriteAllText((Join-Path $repository 'Tools\windows\Invoke-UiCapture.ps1'), '# Offline tooling-only delta') - if ($Mutation -eq 'product-diff') { + if ($Mutation -in @('product-diff', 'omitted-product-diff')) { [IO.File]::WriteAllText((Join-Path $repository 'graphcode-windows\product.txt'), 'UNREVIEWED product') } $null = Invoke-CapTestGit $repository @('add', '.') $null = Invoke-CapTestGit $repository @('commit', '--quiet', '-m', 'Offline candidate') $head = [string](Invoke-CapTestGit $repository @('rev-parse', 'HEAD')) + if ($Mutation -eq 'unrelated-base') { + $tree = [string](Invoke-CapTestGit $repository @('rev-parse', "$base`^{tree}")) + $base = [string](Invoke-CapTestGit $repository @('commit-tree', $tree, '-m', 'Offline unrelated base')) + } $Job.head = $head $Job.base = $base if ($Mutation -eq 'lane-d') { $Job.lane = 'D' } @@ -351,6 +355,17 @@ function New-CapAdmissionJob { ci = Write-CapTestArtifact (Join-Path $Owned 'ci.json') $ci } $Job.admission.ci.runs = @([ordered]@{ id = 123; attempt = 1 }) + if ($Mutation -eq 'omitted-product-diff') { + $proofPath = $Job.admission.equivalence.path + $proof = Get-Content -LiteralPath $proofPath -Raw | ConvertFrom-Json -DateKind String + $proof.headDeltaPaths = @($proof.headDeltaPaths | Where-Object { $_ -cne 'graphcode-windows/product.txt' }) + $Job.admission.equivalence = Write-CapTestArtifact $proofPath $proof + } + if ($Mutation -eq 'phase-swap') { + $before = $Job.binaries.before + $Job.binaries.before = $Job.binaries.after + $Job.binaries.after = $before + } if ($Mutation -eq 'duplicate-json') { $path = $Job.admission.ci.path [IO.File]::WriteAllText($path, [IO.File]::ReadAllText($path).Insert(1, "`"head`":`"$head`",")) @@ -395,7 +410,8 @@ try { $target = New-CapHarmlessTarget if (-not $LaunchContractOnly) { foreach ($mutation in @('accepted', 'lane-d', 'red-ci', 'binary-hash', 'product-diff', - 'ci-head', 'empty-ci', 'source-bytes', 'lease-base', 'duplicate-ci', 'duplicate-json', 'null-build', 'stale-build')) { + 'ci-head', 'empty-ci', 'source-bytes', 'lease-base', 'duplicate-ci', 'duplicate-json', 'null-build', 'stale-build', + 'omitted-product-diff', 'phase-swap', 'unrelated-base')) { Invoke-CapTest "lease-bound admission $mutation through actual capture command" { $run = Invoke-CapCaptureCommand -Name "admission-$mutation" -AdmissionMutation $mutation if ($mutation -in @('accepted', 'lane-d')) { @@ -419,6 +435,9 @@ try { 'duplicate-json' { 'Duplicate JSON field' } 'null-build' { "field 'sourceCommit'" } 'stale-build' { 'Fresh build provenance must name the exact phase source' } + 'omitted-product-diff' { 'Equivalence proof does not name the complete exact Git delta' } + 'phase-swap' { 'Fresh build provenance must name the exact phase source' } + 'unrelated-base' { 'merge-base --is-ancestor' } } Assert-CapTest ($run.child.exitCode -ne 0 -and $null -ne $run.receipt -and $run.receipt.status -ceq 'REFUSED_NO_WORKER' -and -not $run.receipt.workerStarted -and diff --git a/Tools/windows/UiCaptureDriver.psm1 b/Tools/windows/UiCaptureDriver.psm1 index 997b0264..37ac42fd 100644 --- a/Tools/windows/UiCaptureDriver.psm1 +++ b/Tools/windows/UiCaptureDriver.psm1 @@ -1603,11 +1603,13 @@ function Read-CapBoundJson { param([Parameter(Mandatory)][string]$Root, [Parameter(Mandatory)]$Reference) Assert-CapDataRecord $Reference @{ path = [string]; sha256 = [string] } 'Artifact reference' $path = Assert-CapArtifactPath -Root $Root -Path $Reference.path + $bytes = [IO.File]::ReadAllBytes($path) if ($Reference.sha256 -notmatch '^[A-Fa-f0-9]{64}$' -or - (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash -ne $Reference.sha256) { + [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($bytes)) -ne $Reference.sha256) { throw "Bound artifact hash mismatch: $path" } - $json = [IO.File]::ReadAllText($path) + $offset = if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) { 3 } else { 0 } + $json = [Text.UTF8Encoding]::new($false, $true).GetString($bytes, $offset, $bytes.Length - $offset) $document = [Text.Json.JsonDocument]::Parse($json) try { $visit = { @@ -1629,8 +1631,8 @@ function Read-CapBoundJson { function Invoke-CapAdmissionGit { param([Parameter(Mandatory)][string]$Repository, [Parameter(Mandatory)][string[]]$Arguments) - $output = @(& git --no-pager -c core.quotepath=false -C $Repository @Arguments 2>&1) - if ($LASTEXITCODE -ne 0) { throw "Source identity Git command failed: $($output -join [Environment]::NewLine)" } + $output = @(& git --no-pager --no-optional-locks -c core.quotepath=false -c core.fsmonitor=false -C $Repository @Arguments 2>&1) + if ($LASTEXITCODE -ne 0) { throw "Source identity Git command failed ($($Arguments -join ' ')): $($output -join [Environment]::NewLine)" } return $output } @@ -1716,6 +1718,7 @@ function Assert-CapJobIdentity { [string](Invoke-CapAdmissionGit $repository @('rev-parse', '--verify', "$commit`^{commit}")) -cne $commit) { throw 'Named source head/base/reviewed head is not an existing exact Git commit.' } + $null = Invoke-CapAdmissionGit $repository @('merge-base', '--is-ancestor', $Job.base, $Job.head) } if ([string](Invoke-CapAdmissionGit $repository @('rev-parse', 'HEAD')) -cne $Job.head -or @(Invoke-CapAdmissionGit $repository @('status', '--porcelain', '--untracked-files=all')).Count -ne 0) { From 826007d01b158f665b92b59b3374072ad9acb87f Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Wed, 14 Oct 2026 18:00:39 -0700 Subject: [PATCH 3/4] Exercise distinct phase binary swaps in admission negatives Refs #822 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: fc358da6-6436-4c4b-8086-c4778016cd55 Signed-off-by: Colin Neilens --- Tools/windows/Tests/UiCaptureDriver.Tests.ps1 | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 index cfd184e7..ab0ba63a 100644 --- a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 +++ b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 @@ -292,6 +292,8 @@ function New-CapAdmissionJob { [IO.File]::WriteAllText($logPath, 'OFFLINE_TEST_DATA: no product build was performed.') $binary = Join-Path $Owned "$phase.exe" Copy-Item -LiteralPath $target.executable -Destination $binary + [IO.File]::AppendAllText($binary, "OFFLINE_DATA_ONLY_PHASE_$phase") + $binaryHash = (Get-FileHash -LiteralPath $binary -Algorithm SHA256).Hash $receipt = [ordered]@{ schemaVersion = 1; evidenceMode = 'OFFLINE_TEST_DATA_NOT_PRODUCT_BUILD' sourceCommit = $commit; sourceTree = [string](Invoke-CapTestGit $repository @('rev-parse', "${commit}:graphcode-windows")) @@ -299,7 +301,7 @@ function New-CapAdmissionJob { binaryBuiltAtSource = $commit archive = [ordered]@{ path = $archive; sha256 = (Get-FileHash $archive -Algorithm SHA256).Hash } log = [ordered]@{ path = $logPath; sha256 = (Get-FileHash $logPath -Algorithm SHA256).Hash } - executablePath = $binary; executableSha256 = $target.executableSha256 + executablePath = $binary; executableSha256 = $binaryHash startedUtc = [DateTime]::UtcNow.AddSeconds(-5).ToString('o') finishedUtc = [DateTime]::UtcNow.AddSeconds(-1).ToString('o') exitCode = 0; command = 'OFFLINE_TEST_DATA: existing harmless fixture, not a product build' @@ -313,7 +315,7 @@ function New-CapAdmissionJob { if ($phase -eq 'after' -and $Mutation -eq 'null-build') { $receipt.sourceCommit = $null } if ($phase -eq 'after' -and $Mutation -eq 'stale-build') { $receipt.binaryBuiltAtSource = $base } $Job.binaries[$phase] = [ordered]@{ - path = $binary; sha256 = $target.executableSha256; source = $commit; binaryBuiltAtSource = $commit + path = $binary; sha256 = $binaryHash; source = $commit; binaryBuiltAtSource = $commit buildReceipt = Write-CapTestArtifact (Join-Path $Owned "$phase-build.json") $receipt } } @@ -362,9 +364,11 @@ function New-CapAdmissionJob { $Job.admission.equivalence = Write-CapTestArtifact $proofPath $proof } if ($Mutation -eq 'phase-swap') { - $before = $Job.binaries.before - $Job.binaries.before = $Job.binaries.after - $Job.binaries.after = $before + Assert-CapTest ($Job.binaries.before.sha256 -cne $Job.binaries.after.sha256) 'Phase fixture binary hashes must differ.' + $before = [IO.File]::ReadAllBytes($Job.binaries.before.path) + $after = [IO.File]::ReadAllBytes($Job.binaries.after.path) + [IO.File]::WriteAllBytes($Job.binaries.before.path, $after) + [IO.File]::WriteAllBytes($Job.binaries.after.path, $before) } if ($Mutation -eq 'duplicate-json') { $path = $Job.admission.ci.path @@ -436,7 +440,7 @@ try { 'null-build' { "field 'sourceCommit'" } 'stale-build' { 'Fresh build provenance must name the exact phase source' } 'omitted-product-diff' { 'Equivalence proof does not name the complete exact Git delta' } - 'phase-swap' { 'Fresh build provenance must name the exact phase source' } + 'phase-swap' { 'before binary hash mismatch' } 'unrelated-base' { 'merge-base --is-ancestor' } } Assert-CapTest ($run.child.exitCode -ne 0 -and $null -ne $run.receipt -and From 67c515924f4bad1ac5bc40fdb800dd9ef9c301cc Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Wed, 14 Oct 2026 18:57:28 -0700 Subject: [PATCH 4/4] Preserve journal snapshots during atomic worker updates Refs #822 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: fc358da6-6436-4c4b-8086-c4778016cd55 Signed-off-by: Colin Neilens --- Tools/windows/Invoke-UiCapture.ps1 | 21 +++++++++- Tools/windows/Invoke-UiCaptureWorker.ps1 | 7 +++- Tools/windows/Tests/UiCaptureDriver.Tests.ps1 | 39 +++++++++++++++++++ 3 files changed, 64 insertions(+), 3 deletions(-) diff --git a/Tools/windows/Invoke-UiCapture.ps1 b/Tools/windows/Invoke-UiCapture.ps1 index a2448e30..b15d0995 100644 --- a/Tools/windows/Invoke-UiCapture.ps1 +++ b/Tools/windows/Invoke-UiCapture.ps1 @@ -187,7 +187,8 @@ function Assert-CapPreflightReport { 'lease-bound admission lease-base through actual capture command', 'lease-bound admission omitted-product-diff through actual capture command', 'lease-bound admission phase-swap through actual capture command', - 'lease-bound admission unrelated-base through actual capture command' + 'lease-bound admission unrelated-base through actual capture command', + 'actual journal reader permits worker atomic replacement while retaining its snapshot' )) { if (@($preflight.cases | Where-Object name -CEQ $required).Count -ne 1) { throw "Repaired-core preflight is missing a unique required actual-command case: $required" @@ -403,12 +404,28 @@ function Add-CapTrackedIdentity { } } +function Open-CapJournalRead { + param([Parameter(Mandatory)][string]$Path) + if (Get-Command Write-CapTrace -ErrorAction SilentlyContinue) { Write-CapTrace -Command $MyInvocation.MyCommand.Name -Parameters $PSBoundParameters } + + # A polling reader must not prevent the worker from atomically replacing its journal. + return [IO.File]::Open($Path, [IO.FileMode]::Open, [IO.FileAccess]::Read, + ([IO.FileShare]::ReadWrite -bor [IO.FileShare]::Delete)) +} + function Refresh-CapJournal { param([Parameter(Mandatory)][string]$Path) if (Get-Command Write-CapTrace -ErrorAction SilentlyContinue) { Write-CapTrace -Command $MyInvocation.MyCommand.Name -Parameters $PSBoundParameters } if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return } - try { $journal = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json } + try { + $stream = Open-CapJournalRead -Path $Path + try { + $reader = [IO.StreamReader]::new($stream, [Text.UTF8Encoding]::new($false, $true), $true) + try { $journal = $reader.ReadToEnd() | ConvertFrom-Json } + finally { $reader.Dispose() } + } finally { $stream.Dispose() } + } catch { throw "Worker process journal is malformed: $($_.Exception.Message)" } if ($journal.leaseId -ne $lease.leaseId -or $journal.jobSha256 -ne $jobHash -or $journal.moduleSha256 -ne $moduleHash -or $journal.workerSha256 -ne $workerHash) { diff --git a/Tools/windows/Invoke-UiCaptureWorker.ps1 b/Tools/windows/Invoke-UiCaptureWorker.ps1 index a3a4874f..666b9513 100644 --- a/Tools/windows/Invoke-UiCaptureWorker.ps1 +++ b/Tools/windows/Invoke-UiCaptureWorker.ps1 @@ -62,7 +62,12 @@ function Write-CapJsonAtomic { $temporary = "$Path.tmp" [IO.File]::WriteAllText($temporary, (ConvertTo-Json -InputObject $Value -Depth 40), [Text.UTF8Encoding]::new($false)) - [IO.File]::Move($temporary, $Path, $true) + if ([IO.File]::Exists($Path)) { + # ReplaceFile retains old reader snapshots; NullString supplies a true null backup path. + [IO.File]::Replace($temporary, $Path, [System.Management.Automation.Language.NullString]::Value) + } else { + [IO.File]::Move($temporary, $Path) + } } function Assert-CapWorkerDeadline { diff --git a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 index ab0ba63a..344f2e76 100644 --- a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 +++ b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 @@ -667,6 +667,45 @@ try { $null = Assert-CapRejects { Assert-CapFixtureMapping @parameters } 'owned project path' return 'Actual shared root/source/mapping predicates only; no product binary, HWND, native compatibility, or live capture was executed.' } + Invoke-CapTest 'actual journal reader permits worker atomic replacement while retaining its snapshot' { + foreach ($definition in @( + @($supervisorPath, 'Open-CapJournalRead'), + @($workerPath, 'Write-CapJsonAtomic') + )) { + $ast = [Management.Automation.Language.Parser]::ParseInput( + [IO.File]::ReadAllText($definition[0]), [ref]$null, [ref]$null) + $function = $ast.Find({ param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -ceq $definition[1] + }, $true) + Assert-CapTest ($null -ne $function) "Actual journal function missing: $($definition[1])" + . ([scriptblock]::Create($function.Extent.Text)) + } + $directory = Join-Path $scratch 'journal-sharing-component' + $null = New-Item -ItemType Directory -Path $directory + $path = Join-Path $directory 'process-journal.json' + Write-CapJsonAtomic -Path $path -Value ([ordered]@{ version = 1; componentOnly = $true; cleanupComplete = $false }) + $stream = Open-CapJournalRead -Path $path + try { + Write-CapJsonAtomic -Path $path -Value ([ordered]@{ + version = 2; componentOnly = $true; cleanupComplete = $true + ownedProcessSurvivors = @(); pendingLaunchesRemaining = @(); cleanupFailures = @() + }) + $reader = [IO.StreamReader]::new($stream) + try { $snapshot = $reader.ReadToEnd() | ConvertFrom-Json } + finally { $reader.Dispose() } + $freshStream = Open-CapJournalRead -Path $path + try { + $freshReader = [IO.StreamReader]::new($freshStream) + try { $current = $freshReader.ReadToEnd() | ConvertFrom-Json } + finally { $freshReader.Dispose() } + } finally { $freshStream.Dispose() } + Assert-CapTest ($snapshot.version -eq 1 -and -not $snapshot.cleanupComplete -and + $current.version -eq 2 -and $current.cleanupComplete -and $current.ownedProcessSurvivors.Count -eq 0 -and + $current.pendingLaunchesRemaining.Count -eq 0 -and $current.cleanupFailures.Count -eq 0) ` + 'Atomic replacement lost the held snapshot or a fresh read mistook it for completed cleanup.' + } finally { $stream.Dispose() } + return 'Actual supervisor reader and actual worker atomic writer; held file I/O only, no process, lease or GUI mock.' + } Invoke-CapTest 'actual rehearsal data satisfies the repaired report gate and refuses stale proof' { $source = [IO.File]::ReadAllText($supervisorPath) $ast = [Management.Automation.Language.Parser]::ParseInput($source, [ref]$null, [ref]$null)