scripts/internal/continuous-co-review/review-target-port.ps1
|
$ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest # F-198 / T046: target-neutral review snapshot port. The production code adapter uses a real # external `git worktree` sharing the origin object database; the thin non-code adapter proves the # port is not code-specific. The origin remains the sole product-code mutation authority. if (-not (Get-Command -Name 'Resolve-ReviewCurrentness' -ErrorAction SilentlyContinue)) { . (Join-Path $PSScriptRoot 'review-authority-core.ps1') } if (-not (Get-Command -Name 'Get-ContinuousCoReviewReviewedStateDigest' -ErrorAction SilentlyContinue)) { . (Join-Path $PSScriptRoot 'reviewed-state-digest.ps1') } # HARD dependency (DRIFT-198-I009-018): absent, target containment silently compared with a # DIFFERENT case rule instead of the volume's own. if (-not (Get-Command -Name 'Get-ContinuousCoReviewPathCaseSensitive' -ErrorAction SilentlyContinue)) { . (Join-Path $PSScriptRoot 'path-identity.ps1') } function Invoke-ReviewTargetGit { param( [Parameter(Mandatory)][string]$WorkingDirectory, [Parameter(Mandatory)][string[]]$Arguments ) $start = [System.Diagnostics.ProcessStartInfo]::new() $start.FileName = 'git' # Git for Windows keeps Win32's legacy MAX_PATH behavior unless # core.longpaths is enabled. Review snapshots routinely contain deeply # nested package/test paths; cleanup must not strand an unregistered # disposable worktree after verification. Apply this process-locally so # Specrew does not mutate the user's Git configuration. if ([OperatingSystem]::IsWindows()) { [void]$start.ArgumentList.Add('-c') [void]$start.ArgumentList.Add('core.longpaths=true') } foreach ($argument in $Arguments) { [void]$start.ArgumentList.Add([string]$argument) } $start.WorkingDirectory = $WorkingDirectory $start.UseShellExecute = $false $start.CreateNoWindow = $true $start.RedirectStandardOutput = $true $start.RedirectStandardError = $true $start.StandardOutputEncoding = [System.Text.UTF8Encoding]::new($false) $start.StandardErrorEncoding = [System.Text.UTF8Encoding]::new($false) $process = [System.Diagnostics.Process]::new(); $process.StartInfo = $start [void]$process.Start() # Drain both redirected pipes concurrently. Large trees can fill either pipe; sequential # ReadToEnd calls can deadlock while Git waits for the other pipe to drain. $stdoutTask = $process.StandardOutput.ReadToEndAsync() $stderrTask = $process.StandardError.ReadToEndAsync() $process.WaitForExit() $stdout = $stdoutTask.GetAwaiter().GetResult() $stderr = $stderrTask.GetAwaiter().GetResult() $exitCode = $process.ExitCode $process.Dispose() return [pscustomobject]@{ exit_code = $exitCode stdout = $stdout.Trim() stderr = $stderr.Trim() stdout_raw = $stdout stderr_raw = $stderr } } function Get-GitReviewTargetTreeEntries { [CmdletBinding()] param( [Parameter(Mandatory)][string]$WorkingDirectory, [Parameter(Mandatory)][string]$TreeId, [AllowEmptyCollection()][string[]]$Pathspec = @() ) $arguments = @('ls-tree', '-r', '-z', $TreeId) if (@($Pathspec).Count -gt 0) { $arguments += @('--') + @($Pathspec) } $listed = Invoke-ReviewTargetGit -WorkingDirectory $WorkingDirectory -Arguments $arguments if ($listed.exit_code -ne 0) { throw ('review-target-tree-list-failed:' + $listed.stderr) } if (-not $listed.PSObject.Properties['stdout_raw']) { throw 'review-target-tree-list-raw-output-missing' } $raw = [string]$listed.stdout_raw if ($raw.Contains([char]0xFFFD)) { throw 'review-target-tree-path-undecodable' } $entries = [Collections.Generic.List[object]]::new() foreach ($record in @($raw -split ([string][char]0))) { if ([string]::IsNullOrEmpty($record)) { continue } $match = [regex]::Match( $record, '^(?<mode>[0-9]{6}) (?<type>[^ ]+) (?<oid>[0-9a-fA-F]{40,64})\t(?<path>[\s\S]+)$', [Text.RegularExpressions.RegexOptions]::CultureInvariant ) if (-not $match.Success) { throw 'review-target-tree-entry-invalid' } $entries.Add([pscustomobject]@{ mode = $match.Groups['mode'].Value type = $match.Groups['type'].Value object_id = $match.Groups['oid'].Value.ToLowerInvariant() path = $match.Groups['path'].Value }) | Out-Null } return @($entries) } function Test-GitReviewTargetSymlinkContained { [CmdletBinding()] param( [Parameter(Mandatory)][string]$LinkPath, [Parameter(Mandatory)][AllowEmptyString()][string]$Target ) if ([string]::IsNullOrEmpty($Target) -or $Target.Contains([char]0) -or $Target.Contains([char]0xFFFD)) { return $false } # Apply both POSIX and Windows rooted-path rules regardless of the controller OS: a tree # accepted on one host must remain safe when reviewed on another. if ($Target -match '^[\\/]' -or $Target -match '^[A-Za-z]:') { return $false } $segments = [Collections.Generic.List[string]]::new() $parent = ([string]$LinkPath -replace '\\', '/') -replace '/[^/]+$', '' if ($parent -cne $LinkPath) { foreach ($part in @($parent -split '/')) { if (-not [string]::IsNullOrEmpty($part)) { $segments.Add($part) | Out-Null } } } foreach ($part in @(($Target -replace '\\', '/') -split '/')) { if ([string]::IsNullOrEmpty($part) -or $part -ceq '.') { continue } if ($part -ceq '..') { if ($segments.Count -eq 0) { return $false } $segments.RemoveAt($segments.Count - 1) continue } $segments.Add($part) | Out-Null } return $true } function Assert-GitReviewTargetTreeSymlinksContained { [CmdletBinding()] param( [Parameter(Mandatory)][string]$WorkingDirectory, [Parameter(Mandatory)][string]$TreeId ) foreach ($entry in @(Get-GitReviewTargetTreeEntries -WorkingDirectory $WorkingDirectory -TreeId $TreeId)) { if ([string]$entry.mode -cne '120000') { continue } $blob = Invoke-ReviewTargetGit -WorkingDirectory $WorkingDirectory -Arguments @('cat-file', 'blob', [string]$entry.object_id) if ($blob.exit_code -ne 0 -or -not $blob.PSObject.Properties['stdout_raw']) { throw ('review-target-symlink-unreadable:' + [string]$entry.path) } if (-not (Test-GitReviewTargetSymlinkContained -LinkPath ([string]$entry.path) -Target ([string]$blob.stdout_raw))) { # Never echo the target: an external absolute target may itself disclose host paths. throw ('review-target-symlink-escape:' + [string]$entry.path) } } } function Get-ReviewTargetSuppressionEnvironment { # These are process-local reviewer controls, not ambient authority switches. The checked-in target # has Specrew machinery stripped by the canonical digest, and an installed hook that still fires # receives the established all-event no-op signal. return [ordered]@{ SPECREW_REFOCUS_DISABLE = '1' SPECREW_DISABLE_EVENTS = 'SessionStart,UserPromptSubmit,PostToolUse,Stop' } } function Get-ReviewTargetVerificationPlanCapture { [CmdletBinding()] param([Parameter(Mandatory)][string]$RepoRoot) $path = Join-Path $RepoRoot '.specrew/verification-plan.json' if (-not [IO.File]::Exists($path)) { return [pscustomobject]@{ present = $false; sha256 = $null; bytes = $null } } $bytes = [IO.File]::ReadAllBytes($path) $sha256 = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($bytes)).ToLowerInvariant() return [pscustomobject]@{ present = $true; sha256 = $sha256; bytes = $bytes } } function Test-ReviewTargetPathUnderRoot { param([Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][string]$Root) if (Get-Command -Name 'Test-ContinuousCoReviewPathUnderRoot' -ErrorAction SilentlyContinue) { return [bool](Test-ContinuousCoReviewPathUnderRoot -Path $Path -Root $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) # Containment fallback: answer "inside" more readily when the volume cannot be determined, so a # case-aliased path is refused rather than admitted. $comparison = Get-ContinuousCoReviewPathComparison -Path $rootFull -WhenUndetermined 'same' return $pathFull.Equals($rootFull, $comparison) -or $pathFull.StartsWith($rootFull + [IO.Path]::DirectorySeparatorChar, $comparison) } function New-ReviewTargetWorkspaceToken { # Twelve independent random bytes encode to 16 URL-safe characters: 96 source bits (about # 83 bits of namespace entropy after case-folding) with 33 fewer path characters than the # former run-token + full-GUID leaf. $bytes = [Security.Cryptography.RandomNumberGenerator]::GetBytes(12) return [Convert]::ToBase64String($bytes).Replace('+', '-').Replace('/', '_') } function Get-GitReviewTargetOriginEvidence { [CmdletBinding()] param( [Parameter(Mandatory)][string]$OriginRepo, [AllowEmptyCollection()][string[]]$ExcludedPathPatterns = @() ) $origin = (Resolve-Path -LiteralPath $OriginRepo).Path $head = Invoke-ReviewTargetGit -WorkingDirectory $origin -Arguments @('rev-parse', 'HEAD') if ($head.exit_code -ne 0 -or [string]::IsNullOrWhiteSpace($head.stdout)) { throw ('review-target-head-unavailable:' + $head.stderr) } $digest = Get-ContinuousCoReviewReviewedStateDigest -RepoRoot $origin -ExcludedPathPatterns $ExcludedPathPatterns if ($null -eq $digest -or -not $digest.ok -or [string]::IsNullOrWhiteSpace([string]$digest.tree_id)) { $reason = if ($null -ne $digest) { [string]$digest.failure_reason } else { 'null-digest' } throw "review-target-digest-unavailable:$reason" } $machineryPaths = Get-ContinuousCoReviewOrdinalUniquePath -Path @($digest.machinery_paths | ForEach-Object { ([string]$_ -replace '\\', '/').Trim('/') }) $machineryPathBytes = [Text.Encoding]::UTF8.GetBytes(($machineryPaths -join "`n")) $machineryPathsSha256 = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($machineryPathBytes)).ToLowerInvariant() return [pscustomobject]@{ origin_head = $head.stdout; reviewed_state_digest = [string]$digest.tree_id machinery_paths = @($machineryPaths); machinery_paths_sha256 = $machineryPathsSha256 excluded_path_patterns = @($digest.excluded_path_patterns) excluded_path_patterns_sha256 = [string]$digest.excluded_path_patterns_sha256 } } function New-GitReviewTargetSnapshot { [CmdletBinding()] param( [Parameter(Mandatory)][string]$OriginRepo, [Parameter(Mandatory)][string]$RunId, [string]$ExternalRoot, [AllowEmptyCollection()][string[]]$ExcludedPathPatterns = @() ) if (-not (Test-ReviewAuthorityIdentifier -Value $RunId -Kind run)) { throw "review-target-invalid-run-id:$RunId" } $origin = (Resolve-Path -LiteralPath $OriginRepo).Path $gitRootResult = Invoke-ReviewTargetGit -WorkingDirectory $origin -Arguments @('rev-parse', '--show-toplevel') if ($gitRootResult.exit_code -ne 0) { throw ('review-target-not-git:' + $gitRootResult.stderr) } $gitRoot = [IO.Path]::GetFullPath($gitRootResult.stdout) $prefixResult = Invoke-ReviewTargetGit -WorkingDirectory $origin -Arguments @('rev-parse', '--show-prefix') if ($prefixResult.exit_code -ne 0) { throw ('review-target-prefix-unavailable:' + $prefixResult.stderr) } $prefix = $prefixResult.stdout.Trim().TrimEnd('/') $before = Get-GitReviewTargetOriginEvidence -OriginRepo $origin -ExcludedPathPatterns $ExcludedPathPatterns # The canonical code digest deliberately excludes .specrew/**. The selected verification plan is # nevertheless a project-owned campaign input, so freeze its exact bytes alongside the code tree and # bind its hash into the target's currentness check. It is never read live after this capture. $verificationPlan = Get-ReviewTargetVerificationPlanCapture -RepoRoot $origin if ([string]::IsNullOrWhiteSpace($ExternalRoot)) { $ExternalRoot = Join-Path ([IO.Path]::GetTempPath()) 'specrew-review-targets' } $externalFull = [IO.Path]::GetFullPath($ExternalRoot) if ((Test-ReviewTargetPathUnderRoot -Path $externalFull -Root $gitRoot) -or (Test-ReviewTargetPathUnderRoot -Path $externalFull -Root $origin)) { throw 'review-target-external-root-inside-origin' } [IO.Directory]::CreateDirectory($externalFull) | Out-Null # The full run ID remains in immutable authority metadata. A URL-safe token drawn from 96 # independent random bits keeps the filesystem name bounded with strong cross-run uniqueness. $workspaceRoot = Join-Path $externalFull ('rt-' + (New-ReviewTargetWorkspaceToken)) if ([IO.Directory]::Exists($workspaceRoot) -or [IO.File]::Exists($workspaceRoot)) { throw 'review-target-workspace-collision' } Assert-GitReviewTargetTreeSymlinksContained -WorkingDirectory $gitRoot -TreeId $before.reviewed_state_digest $added = $false try { # Create a genuine linked worktree (shared objects), then checkout the canonical current-state # tree into that worktree's private index without moving any origin ref/index/working file. $add = Invoke-ReviewTargetGit -WorkingDirectory $gitRoot -Arguments @('worktree', 'add', '--detach', '--no-checkout', $workspaceRoot, 'HEAD') if ($add.exit_code -ne 0) { throw ('review-target-worktree-add-failed:' + $add.stderr) } $added = $true $checkout = Invoke-ReviewTargetGit -WorkingDirectory $workspaceRoot -Arguments @('read-tree', '--reset', '-u', $before.reviewed_state_digest) if ($checkout.exit_code -ne 0) { throw ('review-target-tree-checkout-failed:' + $checkout.stderr) } $snapshotPath = if ([string]::IsNullOrWhiteSpace($prefix)) { $workspaceRoot } else { Join-Path $workspaceRoot $prefix } if (-not [IO.Directory]::Exists($snapshotPath)) { throw 'review-target-snapshot-subtree-missing' } if ((Test-ReviewTargetPathUnderRoot -Path $workspaceRoot -Root $gitRoot) -or (Test-ReviewTargetPathUnderRoot -Path $workspaceRoot -Root $origin)) { throw 'review-target-worktree-inside-origin' } if (-not (Get-Command -Name 'Get-ContinuousCoReviewWorktreeSourceHashes' -ErrorAction SilentlyContinue)) { . (Join-Path $PSScriptRoot 'worktree-reviewer.ps1') } $sourceHashes = Get-ContinuousCoReviewWorktreeSourceHashes -WorktreePath $snapshotPath return [pscustomobject]@{ schema_version = '1.0'; target_kind = 'code'; run_id = $RunId; target_digest = $before.reviewed_state_digest snapshot_path = $snapshotPath; workspace_root = $workspaceRoot; origin_repo = $origin; git_root = $gitRoot origin_head_before = $before.origin_head; origin_digest_before = $before.reviewed_state_digest machinery_paths = @($before.machinery_paths); machinery_paths_sha256 = $before.machinery_paths_sha256 excluded_path_patterns = @($before.excluded_path_patterns) excluded_path_patterns_sha256 = $before.excluded_path_patterns_sha256 verification_plan_present = [bool]$verificationPlan.present; verification_plan_sha256 = $verificationPlan.sha256 verification_plan_bytes = $(if ($verificationPlan.present) { [byte[]]$verificationPlan.bytes } else { $null }) source_hashes_before = $sourceHashes; suppression_environment = Get-ReviewTargetSuppressionEnvironment } } catch { if ($added) { $null = Invoke-ReviewTargetGit -WorkingDirectory $gitRoot -Arguments @('worktree', 'remove', '--force', $workspaceRoot) } # A failed add never proves ownership of a path that appeared after the pre-check. Leave it # untouched; only a successfully registered worktree is safe for this invocation to remove. throw } } function New-GitReviewTargetVerificationCopy { <# Creates a second linked worktree from the already-frozen target identity. Controller verification executes only in this copy; the reviewer target remains byte-identical to its initial baseline. No live origin state is re-resolved here. #> [CmdletBinding()] param( [Parameter(Mandatory)]$Snapshot, [string]$RunId = ([string]$Snapshot.run_id + '-verification') ) foreach ($name in @('target_digest', 'snapshot_path', 'workspace_root', 'git_root', 'origin_repo', 'origin_head_before')) { if (-not $Snapshot.PSObject.Properties[$name] -or [string]::IsNullOrWhiteSpace([string]$Snapshot.$name)) { throw "review-target-verification-copy-missing:$name" } } if (-not (Test-ReviewAuthorityIdentifier -Value $RunId -Kind run)) { throw "review-target-invalid-run-id:$RunId" } $gitRoot = [IO.Path]::GetFullPath([string]$Snapshot.git_root) $externalRoot = [IO.Path]::GetDirectoryName([IO.Path]::GetFullPath([string]$Snapshot.workspace_root)) $workspaceRoot = Join-Path $externalRoot ('rt-' + (New-ReviewTargetWorkspaceToken)) if ([IO.Directory]::Exists($workspaceRoot) -or [IO.File]::Exists($workspaceRoot)) { throw 'review-target-workspace-collision' } Assert-GitReviewTargetTreeSymlinksContained -WorkingDirectory $gitRoot -TreeId ([string]$Snapshot.target_digest) $relativeSnapshot = [IO.Path]::GetRelativePath([IO.Path]::GetFullPath([string]$Snapshot.workspace_root), [IO.Path]::GetFullPath([string]$Snapshot.snapshot_path)) if ($relativeSnapshot.StartsWith('..')) { throw 'review-target-verification-copy-subtree-invalid' } $added = $false try { $add = Invoke-ReviewTargetGit -WorkingDirectory $gitRoot -Arguments @('worktree', 'add', '--detach', '--no-checkout', $workspaceRoot, [string]$Snapshot.origin_head_before) if ($add.exit_code -ne 0) { throw ('review-target-worktree-add-failed:' + $add.stderr) } $added = $true $checkout = Invoke-ReviewTargetGit -WorkingDirectory $workspaceRoot -Arguments @('read-tree', '--reset', '-u', [string]$Snapshot.target_digest) if ($checkout.exit_code -ne 0) { throw ('review-target-tree-checkout-failed:' + $checkout.stderr) } $snapshotPath = if ($relativeSnapshot -ceq '.') { $workspaceRoot } else { Join-Path $workspaceRoot $relativeSnapshot } if (-not [IO.Directory]::Exists($snapshotPath)) { throw 'review-target-snapshot-subtree-missing' } if ([bool]$Snapshot.verification_plan_present) { if (-not $Snapshot.PSObject.Properties['verification_plan_bytes'] -or $null -eq $Snapshot.verification_plan_bytes) { throw 'review-target-verification-copy-plan-bytes-missing' } $bytes = [byte[]]$Snapshot.verification_plan_bytes $sha256 = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($bytes)).ToLowerInvariant() if ($sha256 -cne [string]$Snapshot.verification_plan_sha256) { throw 'review-target-verification-copy-plan-mismatch' } $targetPlan = Join-Path $snapshotPath '.specrew/verification-plan.json' [IO.Directory]::CreateDirectory((Split-Path -Parent $targetPlan)) | Out-Null [IO.File]::WriteAllBytes($targetPlan, $bytes) } if (-not (Get-Command -Name 'Get-ContinuousCoReviewWorktreeSourceHashes' -ErrorAction SilentlyContinue)) { . (Join-Path $PSScriptRoot 'worktree-reviewer.ps1') } return [pscustomobject]@{ schema_version = '1.0'; target_kind = 'code-verification-copy'; run_id = $RunId target_digest = [string]$Snapshot.target_digest; snapshot_path = $snapshotPath; workspace_root = $workspaceRoot origin_repo = [string]$Snapshot.origin_repo; git_root = $gitRoot origin_head_before = [string]$Snapshot.origin_head_before; origin_digest_before = [string]$Snapshot.origin_digest_before machinery_paths = @($Snapshot.machinery_paths); machinery_paths_sha256 = [string]$Snapshot.machinery_paths_sha256 excluded_path_patterns = @($Snapshot.excluded_path_patterns) excluded_path_patterns_sha256 = [string]$Snapshot.excluded_path_patterns_sha256 verification_plan_present = [bool]$Snapshot.verification_plan_present; verification_plan_sha256 = [string]$Snapshot.verification_plan_sha256 source_hashes_before = Get-ContinuousCoReviewWorktreeSourceHashes -WorktreePath $snapshotPath suppression_environment = Get-ReviewTargetSuppressionEnvironment } } catch { if ($added) { $null = Invoke-ReviewTargetGit -WorkingDirectory $gitRoot -Arguments @('worktree', 'remove', '--force', $workspaceRoot) } throw } } function Invoke-ReviewTargetNativeCommand { param( [Parameter(Mandatory)][string]$FileName, [Parameter(Mandatory)][string[]]$Arguments, [ValidateRange(1, 3600)][int]$TimeoutSeconds = 120 ) $start = [Diagnostics.ProcessStartInfo]::new() $start.FileName = $FileName foreach ($argument in $Arguments) { [void]$start.ArgumentList.Add($argument) } $start.UseShellExecute = $false; $start.CreateNoWindow = $true $start.RedirectStandardOutput = $true; $start.RedirectStandardError = $true $process = [Diagnostics.Process]::new(); $process.StartInfo = $start try { [void]$process.Start() # Drain both redirected pipes concurrently. Reading stdout to EOF before # stderr can deadlock when a recursive native command fills stderr (the # real 5,500-file icacls cleanup did exactly that during dogfood review). $stdoutTask = $process.StandardOutput.ReadToEndAsync() $stderrTask = $process.StandardError.ReadToEndAsync() if (-not $process.WaitForExit($TimeoutSeconds * 1000)) { try { $process.Kill($true) } catch { } try { $process.WaitForExit() } catch { } $stdout = try { $stdoutTask.GetAwaiter().GetResult() } catch { '' } $stderr = try { $stderrTask.GetAwaiter().GetResult() } catch { '' } $timeoutReason = "native-command-timeout:${TimeoutSeconds}s" $stderr = if ([string]::IsNullOrWhiteSpace($stderr)) { $timeoutReason } else { $stderr.Trim() + [Environment]::NewLine + $timeoutReason } return [pscustomobject]@{ exit_code = 124; stdout = $stdout.Trim(); stderr = $stderr.Trim() timed_out = $true; timeout_seconds = $TimeoutSeconds } } # WaitForExit(int) can return before asynchronous redirected reads have # observed EOF; the parameterless call completes that drain. $process.WaitForExit() $stdout = $stdoutTask.GetAwaiter().GetResult() $stderr = $stderrTask.GetAwaiter().GetResult() return [pscustomobject]@{ exit_code = $process.ExitCode; stdout = $stdout.Trim(); stderr = $stderr.Trim() timed_out = $false; timeout_seconds = $TimeoutSeconds } } finally { $process.Dispose() } } function Get-ReviewTargetEffectiveUserId { if (-not [OperatingSystem]::IsLinux()) { return $null } $identity = Invoke-ReviewTargetNativeCommand -FileName 'id' -Arguments @('-u') $effectiveUserId = 0 if ($identity.exit_code -ne 0 -or -not [int]::TryParse($identity.stdout, [ref]$effectiveUserId)) { throw ('review-target-effective-user-id-unavailable:' + $identity.stderr) } return $effectiveUserId } function Remove-ReviewTargetWindowsDeny { param([Parameter(Mandatory)][string]$Root, [Parameter(Mandatory)][string]$Sid) $identity = '*' + $Sid $removed = Invoke-ReviewTargetNativeCommand -FileName 'icacls.exe' -Arguments @($Root, '/remove:d', $identity, '/T', '/C', '/Q') if ($removed.exit_code -ne 0) { throw ('icacls-deny-remove-failed:' + $removed.stderr) } } function Disable-ReviewTargetReadOnlyProtection { [CmdletBinding()] param([Parameter(Mandatory)]$Snapshot, [AllowNull()]$Lease) $root = [IO.Path]::GetFullPath([string]$Snapshot.snapshot_path) if (-not [IO.Directory]::Exists($root)) { return [pscustomobject]@{ ok = $true; reason = 'snapshot-already-absent' } } try { if ([OperatingSystem]::IsWindows()) { $sections = [Security.AccessControl.AccessControlSections]::All $currentSid = if ($null -ne $Lease -and $Lease.PSObject.Properties['sid']) { [string]$Lease.sid } else { [Security.Principal.WindowsIdentity]::GetCurrent().User.Value } Remove-ReviewTargetWindowsDeny -Root $root -Sid $currentSid if ($null -ne $Lease -and $Lease.PSObject.Properties['original_sddl'] -and -not [string]::IsNullOrWhiteSpace([string]$Lease.original_sddl)) { $acl = Get-Acl -LiteralPath $root $acl.SetSecurityDescriptorSddlForm([string]$Lease.original_sddl, $sections) Set-Acl -LiteralPath $root -AclObject $acl } } else { $leasePlatform = if ($null -ne $Lease -and $Lease.PSObject.Properties['platform']) { [string]$Lease.platform } else { '' } $mountedReadOnly = $leasePlatform -ceq 'linux-bind-readonly' if (-not $mountedReadOnly -and [OperatingSystem]::IsLinux() -and (Get-ReviewTargetEffectiveUserId) -eq 0) { $mountPoint = Invoke-ReviewTargetNativeCommand -FileName 'mountpoint' -Arguments @('-q', $root) $mountedReadOnly = $mountPoint.exit_code -eq 0 } if ($mountedReadOnly) { $unmounted = Invoke-ReviewTargetNativeCommand -FileName 'umount' -Arguments @($root) if ($unmounted.exit_code -ne 0) { throw ('readonly-bind-unmount-failed:' + $unmounted.stderr) } } $restored = Invoke-ReviewTargetNativeCommand -FileName 'chmod' -Arguments @('-R', 'u+w', $root) if ($restored.exit_code -ne 0) { throw ('chmod-restore-failed:' + $restored.stderr) } } return [pscustomobject]@{ ok = $true; reason = 'review-target-write-protection-removed' } } catch { return [pscustomobject]@{ ok = $false; reason = ('review-target-write-protection-restore-failed:' + $_.Exception.Message) } } } function Enable-ReviewTargetReadOnlyProtection { [CmdletBinding()] param( [Parameter(Mandatory)]$Snapshot, [Parameter(Mandatory)][string]$ExternalWritablePath ) $root = [IO.Path]::GetFullPath([string]$Snapshot.snapshot_path) $external = [IO.Path]::GetFullPath($ExternalWritablePath) if (Test-ReviewTargetPathUnderRoot -Path $external -Root $root) { return [pscustomobject]@{ ok = $false; reason = 'review-target-writable-path-inside-snapshot'; lease = $null } } $externalParent = [IO.Path]::GetDirectoryName($external) if (-not [IO.Directory]::Exists($externalParent)) { return [pscustomobject]@{ ok = $false; reason = 'review-target-writable-parent-missing'; lease = $null } } $lease = $null $existingBlocked = $false $createBlocked = $false $externalWritable = $false try { if ([OperatingSystem]::IsWindows()) { $acl = Get-Acl -LiteralPath $root $originalSddl = $acl.GetSecurityDescriptorSddlForm([Security.AccessControl.AccessControlSections]::All) $currentSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value $denyEntry = ('*{0}:(OI)(CI)(WD,AD,WEA,WA,DE,DC)' -f $currentSid) # Apply an explicit deny to every existing descendant. A root-only inheritable ACE is # insufficient when a checkout contains a child with an explicit allow ACE. $protected = Invoke-ReviewTargetNativeCommand -FileName 'icacls.exe' -Arguments @($root, '/deny', $denyEntry, '/T', '/C', '/Q') if ($protected.exit_code -ne 0) { throw ('icacls-deny-apply-failed:' + $protected.stderr) } $lease = [pscustomobject]@{ platform = 'windows-recursive-deny'; original_sddl = $originalSddl; sid = $currentSid } } elseif ([OperatingSystem]::IsLinux() -and (Get-ReviewTargetEffectiveUserId) -eq 0) { # chmod does not constrain uid 0. A self-bind followed by a read-only remount protects # the entire frozen tree in the reviewer's mount namespace, including against root. $bound = Invoke-ReviewTargetNativeCommand -FileName 'mount' -Arguments @('--bind', $root, $root) if ($bound.exit_code -ne 0) { throw ('readonly-bind-mount-failed:' + $bound.stderr) } $remounted = Invoke-ReviewTargetNativeCommand -FileName 'mount' -Arguments @('-o', 'remount,bind,ro', $root) if ($remounted.exit_code -ne 0) { $null = Invoke-ReviewTargetNativeCommand -FileName 'umount' -Arguments @($root) throw ('readonly-bind-remount-failed:' + $remounted.stderr) } $lease = [pscustomobject]@{ platform = 'linux-bind-readonly'; original_sddl = $null } } else { # No `--`: BSD chmod on macOS does not accept the GNU operand separator here. $protected = Invoke-ReviewTargetNativeCommand -FileName 'chmod' -Arguments @('-R', 'a-w', $root) if ($protected.exit_code -ne 0) { throw ('chmod-protect-failed:' + $protected.stderr) } $lease = [pscustomobject]@{ platform = 'posix'; original_sddl = $null } } $firstFile = Get-ChildItem -LiteralPath $root -Recurse -File -Force -ErrorAction Stop | Select-Object -First 1 if ($null -eq $firstFile) { throw 'review-target-write-protection-no-probe-file' } try { $stream = [IO.File]::Open($firstFile.FullName, [IO.FileMode]::Open, [IO.FileAccess]::Write, [IO.FileShare]::Read) $stream.Dispose() } catch { $existingBlocked = $true } $newPath = Join-Path $root ('.specrew-readonly-probe-' + [guid]::NewGuid().ToString('N')) try { $stream = [IO.File]::Open($newPath, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None) $stream.Dispose() } catch { $createBlocked = $true } $externalProbe = Join-Path $externalParent ('.specrew-external-write-probe-' + [guid]::NewGuid().ToString('N')) try { $stream = [IO.File]::Open($externalProbe, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None) $stream.Dispose(); [IO.File]::Delete($externalProbe); $externalWritable = $true } catch { $externalWritable = $false } if (-not $existingBlocked -or -not $createBlocked -or -not $externalWritable) { $null = Disable-ReviewTargetReadOnlyProtection -Snapshot $Snapshot -Lease $lease if ([IO.File]::Exists($newPath)) { [IO.File]::Delete($newPath) } if ([IO.File]::Exists($externalProbe)) { [IO.File]::Delete($externalProbe) } $detail = 'existing={0};create={1};external={2}' -f $existingBlocked, $createBlocked, $externalWritable return [pscustomobject]@{ ok = $false; reason = ('review-target-write-protection-probe-failed:' + $detail); lease = $null existing_write_blocked = $existingBlocked; create_blocked = $createBlocked; external_writable = $externalWritable } } return [pscustomobject]@{ ok = $true; reason = 'review-target-os-read-only'; lease = $lease existing_write_blocked = $existingBlocked; create_blocked = $createBlocked; external_writable = $externalWritable } } catch { if ($null -ne $lease) { $null = Disable-ReviewTargetReadOnlyProtection -Snapshot $Snapshot -Lease $lease } return [pscustomobject]@{ ok = $false; reason = ('review-target-write-protection-failed:' + $_.Exception.Message); lease = $null } } } function Test-GitReviewTargetCurrentness { [CmdletBinding()] param([Parameter(Mandatory)]$Snapshot) $snapshotExclusions = if ($Snapshot.PSObject.Properties['excluded_path_patterns']) { @($Snapshot.excluded_path_patterns) } else { @() } $after = Get-GitReviewTargetOriginEvidence -OriginRepo ([string]$Snapshot.origin_repo) -ExcludedPathPatterns $snapshotExclusions $decision = Resolve-ReviewCurrentness -ReviewedDigest ([string]$Snapshot.target_digest) -CurrentDigest $after.reviewed_state_digest -OriginHeadBefore ([string]$Snapshot.origin_head_before) -OriginHeadAfter $after.origin_head $reasons = [Collections.Generic.List[string]]::new() if ([string]$decision.reason -cne 'exact-head-and-digest-match') { $reasons.Add([string]$decision.reason) | Out-Null } $currentPlan = Get-ReviewTargetVerificationPlanCapture -RepoRoot ([string]$Snapshot.origin_repo) $capturedPlanPresent = [bool]$Snapshot.verification_plan_present $planCurrent = ($capturedPlanPresent -eq [bool]$currentPlan.present) -and ( (-not $capturedPlanPresent) -or ([string]$Snapshot.verification_plan_sha256 -ceq [string]$currentPlan.sha256) ) if (-not $planCurrent) { $reasons.Add('verification-plan-changed') | Out-Null if ([string]$decision.classification -cne 'unknown') { $decision = [pscustomobject]@{ classification = 'snapshot-moved'; exact = $false; reason = $decision.reason } } } $machineryPathsCurrent = [string]$Snapshot.machinery_paths_sha256 -ceq [string]$after.machinery_paths_sha256 if (-not $machineryPathsCurrent) { $reasons.Add('machinery-paths-changed') | Out-Null if ([string]$decision.classification -cne 'unknown') { $decision = [pscustomobject]@{ classification = 'snapshot-moved'; exact = $false; reason = $decision.reason } } } $exclusionsCurrent = $Snapshot.PSObject.Properties['excluded_path_patterns_sha256'] -and [string]$Snapshot.excluded_path_patterns_sha256 -ceq [string]$after.excluded_path_patterns_sha256 if (-not $exclusionsCurrent) { $reasons.Add('excluded-path-patterns-changed') | Out-Null if ([string]$decision.classification -cne 'unknown') { $decision = [pscustomobject]@{ classification = 'snapshot-moved'; exact = $false; reason = $decision.reason } } } if ($reasons.Count -eq 0) { $reasons.Add('exact-head-and-digest-match') | Out-Null } return [pscustomobject]@{ classification = $decision.classification; exact = $decision.exact; reason = ($reasons -join ','); reasons = @($reasons) origin_head_before = [string]$Snapshot.origin_head_before; origin_head_after = $after.origin_head reviewed_digest = [string]$Snapshot.target_digest; current_digest = $after.reviewed_state_digest verification_plan_current = $planCurrent; verification_plan_sha256 = $Snapshot.verification_plan_sha256 machinery_paths_current = $machineryPathsCurrent; machinery_paths_sha256 = $Snapshot.machinery_paths_sha256 excluded_path_patterns_current = $exclusionsCurrent excluded_path_patterns = @($snapshotExclusions) excluded_path_patterns_sha256 = $Snapshot.excluded_path_patterns_sha256 } } function Test-GitReviewTargetSnapshotIntegrity { [CmdletBinding()] param([Parameter(Mandatory)]$Snapshot) if (-not (Get-Command -Name 'Get-ContinuousCoReviewWorktreeSourceHashes' -ErrorAction SilentlyContinue)) { . (Join-Path $PSScriptRoot 'worktree-reviewer.ps1') } $after = Get-ContinuousCoReviewWorktreeSourceHashes -WorktreePath ([string]$Snapshot.snapshot_path) $before = $Snapshot.source_hashes_before $changed = [System.Collections.Generic.List[string]]::new() # The maps themselves are Ordinal dictionaries (DRIFT-198-I009-024); their key UNION has to use the # SAME comparer or a key the maps kept apart collapses here and its file is never compared, so a # modified path can report intact (DRIFT-198-I009-033). $keys = Get-ContinuousCoReviewOrdinalUniquePath -Path @(@($before.Keys) + @($after.Keys)) foreach ($key in $keys) { $beforeValue = if ($before.ContainsKey($key)) { [string]$before[$key] } else { '<missing>' } $afterValue = if ($after.ContainsKey($key)) { [string]$after[$key] } else { '<missing>' } if ($beforeValue -cne $afterValue) { $changed.Add([string]$key) | Out-Null } } return [pscustomobject]@{ intact = ($changed.Count -eq 0); classification = $(if ($changed.Count -eq 0) { 'intact' } else { 'snapshot-tampered' }); changed_paths = @($changed) } } function Remove-GitReviewTargetSnapshot { [CmdletBinding()] param([Parameter(Mandatory)]$Snapshot) $gitRoot = [string]$Snapshot.git_root; $workspaceRoot = [string]$Snapshot.workspace_root $null = Disable-ReviewTargetReadOnlyProtection -Snapshot $Snapshot -Lease $null $remove = Invoke-ReviewTargetGit -WorkingDirectory $gitRoot -Arguments @('worktree', 'remove', '--force', $workspaceRoot) $null = Invoke-ReviewTargetGit -WorkingDirectory $gitRoot -Arguments @('worktree', 'prune') return [pscustomobject]@{ removed = ($remove.exit_code -eq 0 -and -not [IO.Directory]::Exists($workspaceRoot)); failure_reason = $(if ($remove.exit_code -eq 0) { $null } else { $remove.stderr }) } } function New-NonCodeReviewTargetFixture { [CmdletBinding()] param( [Parameter(Mandatory)][string]$RunId, [Parameter(Mandatory)][AllowEmptyString()][string]$Content, [string]$ExternalRoot ) if (-not (Test-ReviewAuthorityIdentifier -Value $RunId -Kind run)) { throw "review-target-invalid-run-id:$RunId" } if ([string]::IsNullOrWhiteSpace($ExternalRoot)) { $ExternalRoot = Join-Path ([IO.Path]::GetTempPath()) 'specrew-review-targets' } [IO.Directory]::CreateDirectory($ExternalRoot) | Out-Null $workspace = Join-Path ([IO.Path]::GetFullPath($ExternalRoot)) ('review-target-{0}-{1}' -f $RunId, [guid]::NewGuid().ToString('N')) [IO.Directory]::CreateDirectory($workspace) | Out-Null $path = Join-Path $workspace 'artifact.txt' [IO.File]::WriteAllText($path, $Content, [Text.UTF8Encoding]::new($false)) $digest = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData([Text.Encoding]::UTF8.GetBytes($Content))).ToLowerInvariant() return [pscustomobject]@{ schema_version = '1.0'; target_kind = 'non-code-fixture'; run_id = $RunId; target_digest = $digest snapshot_path = $workspace; workspace_root = $workspace; origin_repo = $null; git_root = $null suppression_environment = Get-ReviewTargetSuppressionEnvironment } } function Remove-NonCodeReviewTargetFixture { [CmdletBinding()] param([Parameter(Mandatory)]$Snapshot) $path = [string]$Snapshot.workspace_root if ([IO.Directory]::Exists($path)) { [IO.Directory]::Delete($path, $true) } return [pscustomobject]@{ removed = -not [IO.Directory]::Exists($path); failure_reason = $null } } |