scripts/internal/review-engine-resolution.ps1
|
Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' # HARD dependency on the ONE path-identity primitive. Guarded on the exact function needed rather than # on a sibling name - DRIFT-198-I009-027's shadow survived a guard that probed a DIFFERENT name, and a # stale copy of path-identity.ps1 satisfies the older names while lacking anything added since. if (-not (Get-Command -Name 'Get-ContinuousCoReviewPathComparison' -ErrorAction SilentlyContinue)) { . (Join-Path $PSScriptRoot 'continuous-co-review/path-identity.ps1') } function Test-SpecrewReviewRuntimePathUnderRoot { # This was the SEVENTH OS-family case shortcut, and the structural tests could not see it: they # scanned `scripts/internal/continuous-co-review` rather than `scripts/internal` whole # (DRIFT-198-I009-037). It is not a cosmetic instance - this predicate gates # Assert-SpecrewReviewRuntimePathContained, which authorizes DELETING a file named by an editable # managed-file marker in the target project. On a case-insensitive macOS volume the OS-family rule # chose Ordinal, so a case-aliased path compared as OUTSIDE the root: DRIFT-198-I009-015's exploit # shape on a delete path. Undetermined resolves to 'same' so containment answers "inside" more # readily, which is the REFUSING direction here. param([Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][string]$Root) $pathFull = [IO.Path]::GetFullPath($Path).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) $rootFull = [IO.Path]::GetFullPath($Root).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) $comparison = Get-ContinuousCoReviewPathComparison -Path $rootFull -WhenUndetermined 'same' return $pathFull.Equals($rootFull, $comparison) -or $pathFull.StartsWith($rootFull + [IO.Path]::DirectorySeparatorChar, $comparison) } function Assert-SpecrewReviewRuntimePathContained { # Lexical containment is NOT containment: GetFullPath only folds '..' and separators, so a path # whose ANCESTOR is a symlink/junction to an external directory still compares as under the root # while Get-Item, hashing, and Delete follow that ancestor outside the project. The managed-file # marker is an editable file in the target project supplying both the path AND its expected hash, # so a validly shaped marker could otherwise authorize deleting a matching external file. Reject # a reparse point at EVERY existing component from the root down, then re-verify containment. [CmdletBinding()] param([Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][string]$Root) $rootFull = [IO.Path]::GetFullPath($Root).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) $pathFull = [IO.Path]::GetFullPath($Path) if (-not (Test-SpecrewReviewRuntimePathUnderRoot -Path $pathFull -Root $rootFull)) { throw "review-runtime-managed-path-escapes-root:$Path" } $rootItem = Get-Item -LiteralPath $rootFull -Force -ErrorAction Stop if (($rootItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw "review-runtime-managed-root-link-unsupported:$Root" } $relative = $pathFull.Substring($rootFull.Length).Trim([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) $current = $rootFull foreach ($segment in @($relative -split '[\\/]' | Where-Object { -not [string]::IsNullOrEmpty($_) })) { $current = Join-Path $current $segment if (-not (Test-Path -LiteralPath $current)) { continue } $item = Get-Item -LiteralPath $current -Force -ErrorAction Stop if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw "review-runtime-managed-path-link-unsupported:$Path" } } return $pathFull } function Get-SpecrewReviewRuntimeManagedTextSha256 { [CmdletBinding()] param([Parameter(Mandatory)][string]$Path) $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw "review-runtime-managed-file-link-unsupported:$Path" } $content = [IO.File]::ReadAllText($item.FullName, [Text.Encoding]::UTF8) $normalized = $content.Replace("`r`n", "`n").Replace("`r", "`n") return [Convert]::ToHexString( [Security.Cryptography.SHA256]::HashData([Text.Encoding]::UTF8.GetBytes($normalized)) ).ToLowerInvariant() } function ConvertTo-SpecrewReviewRuntimeManagedFileManifest { [CmdletBinding()] param( [Parameter(Mandatory)][string]$RuntimeRoot, [AllowNull()]$ManagedFiles ) $root = [IO.Path]::GetFullPath($RuntimeRoot) $seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) $normalized = [Collections.Generic.List[object]]::new() foreach ($entry in @($ManagedFiles)) { if ($null -eq $entry -or -not $entry.PSObject.Properties['path'] -or -not $entry.PSObject.Properties['sha256']) { throw 'review-runtime-managed-manifest-entry-invalid' } $relative = ([string]$entry.path -replace '\\', '/').Trim() if ([string]::IsNullOrWhiteSpace($relative) -or $relative.StartsWith('/') -or $relative -match '^[A-Za-z]:' -or @($relative -split '/' | Where-Object { $_ -ceq '..' }).Count -gt 0) { throw "review-runtime-managed-path-unsafe:$relative" } $full = [IO.Path]::GetFullPath((Join-Path $root $relative)) if (-not (Test-SpecrewReviewRuntimePathUnderRoot -Path $full -Root $root) -or $full -ceq $root) { throw "review-runtime-managed-path-unsafe:$relative" } if (-not $seen.Add($relative)) { throw "review-runtime-managed-path-duplicate:$relative" } $sha256 = [string]$entry.sha256 if ($sha256 -cnotmatch '^[a-f0-9]{64}$') { throw "review-runtime-managed-hash-invalid:$relative" } $normalized.Add([pscustomobject][ordered]@{ path = $relative; sha256 = $sha256 }) | Out-Null } return @($normalized | Sort-Object path) } function Get-SpecrewReviewRuntimeManagedFileManifest { [CmdletBinding()] param([Parameter(Mandatory)][string]$RuntimeRoot) $root = (Resolve-Path -LiteralPath $RuntimeRoot -ErrorAction Stop).Path $manifest = foreach ($file in @(Get-ChildItem -LiteralPath $root -File -Recurse -Force | Where-Object { $_.Name -cne '.specrew-runtime.json' } | Sort-Object { ([IO.Path]::GetRelativePath($root, $_.FullName) -replace '\\', '/') })) { $relative = ([IO.Path]::GetRelativePath($root, $file.FullName) -replace '\\', '/') [pscustomobject][ordered]@{ path = $relative sha256 = Get-SpecrewReviewRuntimeManagedTextSha256 -Path $file.FullName } } return @(ConvertTo-SpecrewReviewRuntimeManagedFileManifest -RuntimeRoot $root -ManagedFiles @($manifest)) } function Get-SpecrewReviewRuntimeBundleSha256 { [CmdletBinding()] param( [Parameter(Mandatory)][string]$RuntimeRoot, [AllowNull()]$ManagedFiles ) $root = (Resolve-Path -LiteralPath $RuntimeRoot -ErrorAction Stop).Path $manifest = if ($PSBoundParameters.ContainsKey('ManagedFiles')) { @(ConvertTo-SpecrewReviewRuntimeManagedFileManifest -RuntimeRoot $root -ManagedFiles $ManagedFiles) } else { @(Get-SpecrewReviewRuntimeManagedFileManifest -RuntimeRoot $root) } $hash = [Security.Cryptography.IncrementalHash]::CreateHash([Security.Cryptography.HashAlgorithmName]::SHA256) try { foreach ($entry in $manifest) { $relative = [string]$entry.path $full = [IO.Path]::GetFullPath((Join-Path $root $relative)) if (-not [IO.File]::Exists($full)) { throw "review-runtime-managed-file-missing:$relative" } $hash.AppendData([Text.Encoding]::UTF8.GetBytes($relative)) $hash.AppendData([byte[]]@(0)) # Managed deployment reads and rewrites runtime files as UTF-8 text. # Normalize line endings so an otherwise identical deployed engine does # not fail the handshake solely because of host checkout conventions or # a stripped UTF-8 BOM. $content = [IO.File]::ReadAllText($full, [Text.Encoding]::UTF8) $normalized = $content.Replace("`r`n", "`n").Replace("`r", "`n") $hash.AppendData([Text.Encoding]::UTF8.GetBytes($normalized)) $hash.AppendData([byte[]]@(0)) } return [Convert]::ToHexString($hash.GetHashAndReset()).ToLowerInvariant() } finally { $hash.Dispose() } } function Resolve-SpecrewReviewEngineRoot { [CmdletBinding()] param( [Parameter(Mandatory)][string]$ProjectRoot, [Parameter(Mandatory)][string]$InstalledRuntimeRoot ) $installed = (Resolve-Path -LiteralPath $InstalledRuntimeRoot -ErrorAction Stop).Path $project = (Resolve-Path -LiteralPath $ProjectRoot -ErrorAction Stop).Path $projectRuntime = Join-Path $project 'scripts/internal/continuous-co-review' $projectLoad = Join-Path $projectRuntime '_load.ps1' if (-not [IO.File]::Exists($projectLoad)) { return [pscustomobject][ordered]@{ runtime_root = $installed source = 'installed' bundle_sha256 = Get-SpecrewReviewRuntimeBundleSha256 -RuntimeRoot $installed project_bundle_sha256 = $null marker_present = $false } } $installedHash = Get-SpecrewReviewRuntimeBundleSha256 -RuntimeRoot $installed $markerPath = Join-Path $projectRuntime '.specrew-runtime.json' $marker = $null $projectManagedFiles = $null if ([IO.File]::Exists($markerPath)) { try { $marker = [IO.File]::ReadAllText($markerPath, [Text.Encoding]::UTF8) | ConvertFrom-Json -Depth 8 } catch { throw "review-engine-marker-invalid:${markerPath}:$($_.Exception.Message)" } if ([string]$marker.schema_version -cne '1.0' -or [string]::IsNullOrWhiteSpace([string]$marker.runtime_bundle_sha256)) { throw "review-engine-marker-invalid:$markerPath" } if ($marker.PSObject.Properties['managed_files']) { $projectManagedFiles = @( ConvertTo-SpecrewReviewRuntimeManagedFileManifest -RuntimeRoot $projectRuntime -ManagedFiles $marker.managed_files ) } } $projectHash = if ($null -ne $projectManagedFiles) { Get-SpecrewReviewRuntimeBundleSha256 -RuntimeRoot $projectRuntime -ManagedFiles $projectManagedFiles } else { Get-SpecrewReviewRuntimeBundleSha256 -RuntimeRoot $projectRuntime } if ($null -ne $marker) { if ([string]$marker.runtime_bundle_sha256 -cne $projectHash) { throw "review-engine-project-runtime-drifted: marker=$($marker.runtime_bundle_sha256); actual=$projectHash; run 'specrew update --project-path `"$project`"'" } } if ($projectHash -cne $installedHash) { $markerVersion = if ($null -ne $marker -and -not [string]::IsNullOrWhiteSpace([string]$marker.specrew_version)) { [string]$marker.specrew_version } else { 'unversioned' } throw "review-engine-version-mismatch: installed=$installedHash; project=$projectHash; project_version=$markerVersion; run 'specrew update --project-path `"$project`"' before review" } return [pscustomobject][ordered]@{ runtime_root = $projectRuntime source = 'project-deployed' bundle_sha256 = $installedHash project_bundle_sha256 = $projectHash marker_present = ($null -ne $marker) } } |