Payload/scripts/kaden/harness/lib/HarnessGateFilePolicy.ps1
|
# Gate-file recorder-only policy and provenance stamps. # Dot-sourced into HarnessContract.psm1 so Export-ModuleMember -Function * keeps these helpers exported. # Do not Import-Module this file standalone without Contract helpers already loaded. function Get-HarnessGateFileKind { <# .SYNOPSIS Classify recorder-owned gate paths: projection, journal ledger, or provenance stamp. #> param( [AllowNull()][string]$Path, [string]$RepoRoot = '' ) $n = ConvertTo-HarnessNormalizedRepoPath -Path $Path -RepoRoot $RepoRoot if ([string]::IsNullOrWhiteSpace($n)) { return '' } $leaf = ($n -split '[\\/]' | Select-Object -Last 1) if ($leaf -eq '.review-loop.json') { return 'review-loop' } if ($n -match '(^|/)journal/events\.jsonl$') { return 'journal' } if ($n -match '(^|/)journal/gate-file-provenance\.json$') { return 'provenance' } if ($n -match '(^|/)journal/turn-open\.json$') { return 'turn-open' } return '' } function Test-HarnessGateFileDirectWritePath { <# .SYNOPSIS True when Path is a recorder-owned gate file anywhere in the tree (projection, journal, or provenance stamp). #> param( [AllowNull()][string]$Path, [string]$RepoRoot = '' ) return -not [string]::IsNullOrWhiteSpace((Get-HarnessGateFileKind -Path $Path -RepoRoot $RepoRoot)) } function Get-HarnessGateFileDirectWriteDenyReason { param( [ValidateSet('review-loop', 'journal', 'provenance', 'turn-open', '')][string]$Kind = '' ) if ($Kind -eq 'journal') { return 'journal/events.jsonl is append-only through harness scripts. Use pwsh -File scripts/kaden/harness-invoke.ps1 (harness-journal, harness-record-phase, turn-start/stop); do not edit the journal directly.' } if ($Kind -eq 'provenance') { return 'journal/gate-file-provenance.json is recorder-only. Harness scripts stamp it after legitimate writes; do not edit the provenance stamp directly.' } if ($Kind -eq 'turn-open') { return (Get-HarnessTurnOpenMarkerDenyReason) } return '.review-loop.json is recorder-only. Use pwsh -File scripts/kaden/harness-invoke.ps1 (harness-record-phase, turn-start/stop, init-harness-feature); do not edit the projection directly.' } function Get-HarnessGateFileProvenancePath { param([Parameter(Mandatory = $true)][string]$FeatureRoot) return Join-HarnessPath -Root $FeatureRoot -Child 'journal/gate-file-provenance.json' } function Get-HarnessGateFileSha256 { param([Parameter(Mandatory = $true)][string]$Path) if (-not (Test-Path -LiteralPath $Path)) { return '' } try { $hash = Get-FileHash -LiteralPath $Path -Algorithm SHA256 -ErrorAction Stop if ($null -eq $hash -or [string]::IsNullOrWhiteSpace([string]$hash.Hash)) { return '' } return [string]$hash.Hash.ToLowerInvariant() } catch { return '' } } function Invoke-HarnessGateProvenanceDocumentMutation { <# .SYNOPSIS Exclusive read-modify-write chassis for journal/gate-file-provenance.json (SEC-JAH-01). .DESCRIPTION Shared by gate-file and registered-artifact stamp writers so lock / ForceRepair / rewrite semantics cannot twin-stamp-drift. MutateDocument receives the parsed doc and a caller context object; it mutates the doc in place. Always ensures files and artifacts bags exist before MutateDocument runs. Lock ordering remains journal-append-lock -> provenance-lock. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Non-interactive harness write - agents and hooks must not prompt.')] param( [Parameter(Mandatory = $true)][string]$ProvenancePath, [Parameter(Mandatory = $true)][scriptblock]$MutateDocument, [AllowNull()][object]$Context = $null, [switch]$ForceRepair ) $stampArgs = @($ProvenancePath, [bool]$ForceRepair, $MutateDocument, $Context) $null = Invoke-HarnessWithFileLockRetry -MaxAttempts 8 -DelayMilliseconds 100 -ArgumentList $stampArgs -ScriptBlock { param($LockedProvenancePath, $StampForceRepair, $MutateDocument, $MutateContext) # Exclusive open spans the whole read-modify-write: the doc read below cannot lose # another writer's entry because that writer holds this same lock while writing. $provStream = [System.IO.File]::Open( $LockedProvenancePath, [System.IO.FileMode]::OpenOrCreate, [System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None) try { $provStream.Position = 0 $reader = New-Object System.IO.StreamReader($provStream, [System.Text.Encoding]::UTF8, $false, 4096, $true) $existingText = $reader.ReadToEnd() $doc = $null if (-not [string]::IsNullOrWhiteSpace($existingText)) { try { $doc = $existingText | ConvertFrom-Json } catch { if (-not $StampForceRepair) { throw "gate-file provenance stamp is unreadable at '$LockedProvenancePath'. Pass -ForceRepair to reset after confirming no dual-write forgery, or rebuild via harness scripts." } Write-Warning "Resetting unreadable gate-file provenance stamp at '$LockedProvenancePath' (-ForceRepair)." $doc = $null } } if ($null -eq $doc) { $doc = [pscustomobject]@{ schema = 'harness.gate-file-provenance/v1' files = [pscustomobject]@{} artifacts = [pscustomobject]@{} } } if (-not ($doc.PSObject.Properties.Name -contains 'files') -or $null -eq $doc.files) { $doc | Add-Member -NotePropertyName 'files' -NotePropertyValue ([pscustomobject]@{}) -Force } if (-not ($doc.PSObject.Properties.Name -contains 'artifacts') -or $null -eq $doc.artifacts) { $doc | Add-Member -NotePropertyName 'artifacts' -NotePropertyValue ([pscustomobject]@{}) -Force } & $MutateDocument $doc $MutateContext $json = $doc | ConvertTo-Json -Depth 8 $provStream.Seek(0, [System.IO.SeekOrigin]::Begin) | Out-Null $provStream.SetLength(0) $bytes = [System.Text.Encoding]::UTF8.GetBytes($json + [Environment]::NewLine) $provStream.Write($bytes, 0, $bytes.Length) $provStream.Flush() } finally { $provStream.Dispose() } } } function Read-HarnessGateFileProvenanceDocument { <# .SYNOPSIS Load gate-file-provenance.json with shared fail-closed policy for Test-* helpers. .OUTPUTS PSCustomObject with Doc (nullable) and Findings (list; populated when unreadable). #> param( [Parameter(Mandatory = $true)][string]$FeatureRoot, [Parameter(Mandatory = $true)][string]$OnUnreadableCode ) $findings = [System.Collections.Generic.List[object]]::new() $provPath = Get-HarnessGateFileProvenancePath -FeatureRoot $FeatureRoot if (-not (Test-Path -LiteralPath $provPath)) { return [pscustomobject]@{ Doc = $null; Findings = @() } } try { $doc = Get-Content -LiteralPath $provPath -Raw -Encoding UTF8 | ConvertFrom-Json return [pscustomobject]@{ Doc = $doc; Findings = @() } } catch { $msg = if ($OnUnreadableCode -eq 'GATE_ARTIFACT_DRIFT') { 'gate-file provenance stamp is unreadable during artifact drift check; rebuild via harness scripts (do not hand-edit gate files)' } else { 'gate-file provenance stamp is unreadable; rebuild via harness scripts (do not hand-edit gate files)' } [void]$findings.Add((Format-HarnessValidationFinding -Code $OnUnreadableCode -Message $msg)) return [pscustomobject]@{ Doc = $null; Findings = @($findings) } } } function Write-HarnessGateFileProvenance { <# .SYNOPSIS Stamp the sha256 provenance for one gate file (journal/events.jsonl or .review-loop.json). .DESCRIPTION Delegates exclusive RMW to Invoke-HarnessGateProvenanceDocumentMutation (SEC-JAH-01). Lock ordering is by design journal-append-lock -> provenance-lock. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Non-interactive harness write - agents and hooks must not prompt.')] param( [Parameter(Mandatory = $true)][string]$FeatureRoot, [Parameter(Mandatory = $true)][ValidateSet('review-loop', 'journal')][string]$GateFile, [string]$Source = 'harness-script', [string]$Sha256 = '', [switch]$ForceRepair ) $journalDir = Join-HarnessPath -Root $FeatureRoot -Child 'journal' if (-not (Test-Path -LiteralPath $journalDir)) { New-Item -ItemType Directory -Path $journalDir -Force | Out-Null } $provPath = Get-HarnessGateFileProvenancePath -FeatureRoot $FeatureRoot $targetPath = if ($GateFile -eq 'journal') { Join-HarnessPath -Root $FeatureRoot -Child 'journal/events.jsonl' } else { Join-HarnessPath -Root $FeatureRoot -Child '.review-loop.json' } $hash = if (-not [string]::IsNullOrWhiteSpace($Sha256)) { $Sha256.ToLowerInvariant() } else { Get-HarnessGateFileSha256 -Path $targetPath } $ctx = [pscustomobject]@{ GateFile = $GateFile Hash = $hash Source = $Source } Invoke-HarnessGateProvenanceDocumentMutation -ProvenancePath $provPath -ForceRepair:$ForceRepair -Context $ctx -MutateDocument { param($Doc, $Context) $entry = [pscustomobject]@{ sha256 = $Context.Hash updated_at = (Get-HarnessIso8601UtcNow) source = $Context.Source } $Doc.files | Add-Member -NotePropertyName $Context.GateFile -NotePropertyValue $entry -Force } } function Add-HarnessGateFileProvenanceMismatchFinding { param( [Parameter(Mandatory = $true)] [AllowEmptyCollection()] [System.Collections.Generic.List[object]]$Findings, [Parameter(Mandatory = $true)][string]$FeatureRoot, [Parameter(Mandatory = $true)]$Files, [Parameter(Mandatory = $true)][ValidateSet('review-loop', 'journal')][string]$GateFile, [Parameter(Mandatory = $true)][string]$RelativePath, [Parameter(Mandatory = $true)][string]$Message ) $entry = Get-HarnessProperty -Object $Files -Name $GateFile if ($null -eq $entry) { return } $expected = [string](Get-HarnessProperty -Object $entry -Name 'sha256') $actual = Get-HarnessGateFileSha256 -Path (Join-HarnessPath -Root $FeatureRoot -Child $RelativePath) if (-not [string]::IsNullOrWhiteSpace($expected) -and $actual -ne $expected) { [void]$Findings.Add((Format-HarnessValidationFinding -Code 'DIRECT_GATE_FILE_MUTATION' -Message $Message)) } } function Test-HarnessGateFileProvenance { <# .SYNOPSIS Return DIRECT_GATE_FILE_MUTATION findings when gate-file bytes diverge from script provenance stamps. Missing stamps are ignored (legacy fixtures); mismatch after a script stamp fails closed. #> param([Parameter(Mandatory = $true)][string]$FeatureRoot) $loaded = Read-HarnessGateFileProvenanceDocument -FeatureRoot $FeatureRoot -OnUnreadableCode 'DIRECT_GATE_FILE_MUTATION' if ($null -eq $loaded.Doc) { return @($loaded.Findings) } $findings = [System.Collections.Generic.List[object]]::new() foreach ($f in @($loaded.Findings)) { [void]$findings.Add($f) } $files = Get-HarnessProperty -Object $loaded.Doc -Name 'files' if ($null -eq $files) { return @($findings) } Add-HarnessGateFileProvenanceMismatchFinding -Findings $findings -FeatureRoot $FeatureRoot -Files $files ` -GateFile 'review-loop' -RelativePath '.review-loop.json' ` -Message '.review-loop.json changed without script provenance (projection is recorder-only)' Add-HarnessGateFileProvenanceMismatchFinding -Findings $findings -FeatureRoot $FeatureRoot -Files $files ` -GateFile 'journal' -RelativePath 'journal/events.jsonl' ` -Message 'journal/events.jsonl changed without harness append provenance (journal is append-only through scripts)' return @($findings) } function Write-HarnessRegisteredArtifactProvenance { <# .SYNOPSIS Stamp sha256 custody hash for one registered gate-report artifact under artifacts{} in gate-file-provenance.json. .DESCRIPTION Recorded after sanctioned custody (persist-quality / record-review / record-slice-verdict). Refuses blank hashes and decision-book paths. RelativePath must stay under the feature root (Resolve-HarnessPathUnderFeatureRoot -ThrowOnEscape). Exclusive RMW uses Invoke-HarnessGateProvenanceDocumentMutation (same chassis as Write-HarnessGateFileProvenance). Prefer Complete-HarnessRegisteredArtifactCustodyStamp at recorder call sites. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Non-interactive harness write - agents and hooks must not prompt.')] param( [Parameter(Mandatory = $true)][string]$FeatureRoot, [Parameter(Mandatory = $true)][string]$RelativePath, [string]$Source = 'harness-script', [string]$Slice = '', [string]$Sha256 = '', [switch]$ForceRepair ) $rel = ($RelativePath -replace '\\', '/').TrimStart('/') if ([string]::IsNullOrWhiteSpace($rel)) { throw 'Write-HarnessRegisteredArtifactProvenance requires a non-empty RelativePath.' } # Decision books stay editable by design; never freeze residuals.md or disposition registers. if (Test-HarnessDecisionBookRelativePath -RelativePath $rel) { return } $journalDir = Join-HarnessPath -Root $FeatureRoot -Child 'journal' if (-not (Test-Path -LiteralPath $journalDir)) { New-Item -ItemType Directory -Path $journalDir -Force | Out-Null } $provPath = Get-HarnessGateFileProvenancePath -FeatureRoot $FeatureRoot $targetPath = Resolve-HarnessPathUnderFeatureRoot -FeatureRoot $FeatureRoot -RelativePath $rel -ThrowOnEscape $hash = if (-not [string]::IsNullOrWhiteSpace($Sha256)) { $Sha256.ToLowerInvariant() } else { Get-HarnessGateFileSha256 -Path $targetPath } if ([string]::IsNullOrWhiteSpace($hash)) { throw "Write-HarnessRegisteredArtifactProvenance: empty sha256 for '$rel' (refuse blank custody stamp)." } $ctx = [pscustomobject]@{ Rel = $rel Hash = $hash Source = $Source Slice = $Slice } Invoke-HarnessGateProvenanceDocumentMutation -ProvenancePath $provPath -ForceRepair:$ForceRepair -Context $ctx -MutateDocument { param($Doc, $Context) $entry = [pscustomobject]@{ sha256 = $Context.Hash registered_at = [string](Get-HarnessIso8601UtcNow) source = $Context.Source slice = $Context.Slice } $Doc.artifacts | Add-Member -NotePropertyName $Context.Rel -NotePropertyValue $entry -Force } # Intentionally no pipeline output - callers assign paths separately. } function Complete-HarnessRegisteredArtifactCustodyStamp { <# .SYNOPSIS Sanctioned post-custody hash stamp for all three recorders (persist-quality / record-review / record-slice-verdict). .DESCRIPTION Single indirection over Write-HarnessRegisteredArtifactProvenance so a fourth recorder has one named entry point to call after journal registration. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Non-interactive harness write - agents and hooks must not prompt.')] param( [Parameter(Mandatory = $true)][string]$FeatureRoot, [Parameter(Mandatory = $true)][string]$RelativePath, [string]$Source = 'harness-script', [string]$Slice = '', [string]$Sha256 = '', [switch]$ForceRepair ) Write-HarnessRegisteredArtifactProvenance -FeatureRoot $FeatureRoot -RelativePath $RelativePath ` -Source $Source -Slice $Slice -Sha256 $Sha256 -ForceRepair:$ForceRepair } function Test-HarnessGateArtifactDrift { <# .SYNOPSIS Return GATE_ARTIFACT_DRIFT findings when registered gate-report bytes diverge from custody hashes. .DESCRIPTION Stem is Drift (not Provenance) on purpose: finding code GATE_ARTIFACT_DRIFT and US-RI-05 speak "drift," while Test-HarnessGateFileProvenance covers DIRECT_GATE_FILE_MUTATION on files{}. Load path shares Read-HarnessGateFileProvenanceDocument. When Feature/Slice are supplied, findings include NextCommand enrichment (Add-HarnessGateArtifactDriftFinding stays a foreach). #> param( [Parameter(Mandatory = $true)][string]$FeatureRoot, [string]$Feature = '', [string]$Slice = '' ) $loaded = Read-HarnessGateFileProvenanceDocument -FeatureRoot $FeatureRoot -OnUnreadableCode 'GATE_ARTIFACT_DRIFT' if ($null -eq $loaded.Doc) { $out = [System.Collections.Generic.List[object]]::new() foreach ($f in @($loaded.Findings)) { $code = [string](Get-HarnessProperty -Object $f -Name 'code' -Default 'GATE_ARTIFACT_DRIFT') $msg = [string](Get-HarnessProperty -Object $f -Name 'message' -Default '') $sev = [string](Get-HarnessProperty -Object $f -Name 'severity' -Default '') $next = Get-HarnessValidationNextCommand -Code $code -Feature $Feature -Slice $Slice [void]$out.Add((Format-HarnessValidationFinding -Code $code -Message $msg -Severity $sev -NextCommand $next)) } return @($out) } $findings = [System.Collections.Generic.List[object]]::new() $artifacts = Get-HarnessProperty -Object $loaded.Doc -Name 'artifacts' if ($null -eq $artifacts) { return @() } foreach ($prop in @($artifacts.PSObject.Properties)) { $rel = [string]$prop.Name if ([string]::IsNullOrWhiteSpace($rel)) { continue } # Decision books stay editable by design; never emit GATE_ARTIFACT_DRIFT for them. if (Test-HarnessDecisionBookRelativePath -RelativePath $rel) { continue } $entry = $prop.Value $expected = [string](Get-HarnessProperty -Object $entry -Name 'sha256' -Default '') if ([string]::IsNullOrWhiteSpace($expected)) { continue } $msg = $null try { $full = Resolve-HarnessPathUnderFeatureRoot -FeatureRoot $FeatureRoot -RelativePath $rel -ThrowOnEscape } catch { $msg = "Registered gate-report artifact '$rel' path escapes feature root (GATE_ARTIFACT_DRIFT). Specialist re-issue: write a new report identity, then re-persist through the sanctioned recorder." } if ($null -eq $msg -and -not (Test-Path -LiteralPath $full)) { $msg = "Registered gate-report artifact '$rel' is missing after custody (GATE_ARTIFACT_DRIFT). Specialist re-issue: write a new report identity, then re-persist through the sanctioned recorder." } if ($null -eq $msg) { $actual = Get-HarnessGateFileSha256 -Path $full if ($actual -ne $expected) { $msg = "Registered gate-report artifact '$rel' bytes diverge from custody hash (GATE_ARTIFACT_DRIFT). Specialist re-issue: write a new report identity, then re-persist through the sanctioned recorder (harness-persist-quality / harness-record-review / harness-record-slice-verdict)." } } if ($null -ne $msg) { $next = Get-HarnessValidationNextCommand -Code 'GATE_ARTIFACT_DRIFT' -Feature $Feature -Slice $Slice [void]$findings.Add((Format-HarnessValidationFinding -Code 'GATE_ARTIFACT_DRIFT' -Message $msg -NextCommand $next)) } } return @($findings) } function Add-HarnessGateArtifactDriftFinding { <# .SYNOPSIS Append GATE_ARTIFACT_DRIFT findings into a validation findings list (full + preflight). #> param( [Parameter(Mandatory = $true)] [AllowEmptyCollection()] [System.Collections.IList]$Findings, [Parameter(Mandatory = $true)][string]$FeatureRoot, [string]$Feature = '', [string]$Slice = '' ) foreach ($drift in @(Test-HarnessGateArtifactDrift -FeatureRoot $FeatureRoot -Feature $Feature -Slice $Slice)) { [void]$Findings.Add($drift) } } function Test-HarnessPretooluseSkipJournalAllowed { <# .SYNOPSIS True when -SkipJournal may suppress deny telemetry (isolated FeatureRoot under TEMP only). #> param([AllowNull()][string]$FeatureRoot = '') if ([string]::IsNullOrWhiteSpace($FeatureRoot)) { return $false } if (-not (Test-Path -LiteralPath $FeatureRoot)) { return $false } $full = [System.IO.Path]::GetFullPath($FeatureRoot) $temp = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath()) return $full.StartsWith($temp, [System.StringComparison]::OrdinalIgnoreCase) } |