#!/usr/bin/env pwsh # CASAN H5 Governance Harness - PowerShell port of governance-check.sh # Usage: # governance-check.ps1 [action-name] # # Non-interactive. High-risk denied unless env:CASAN_APPROVAL_DECISION=approve + env:CASAN_APPROVER set. # Exit codes: 0=approved, 2=denied, 64=usage error param( [Parameter(Mandatory=$true, Position=0)][string]$InputFile, [Parameter(Mandatory=$true, Position=1)][string]$OutputFile, [Parameter(Position=2)][string]$ActionName = "agent_step" ) $ErrorActionPreference = "Stop" $scriptDir = Split-Path $MyInvocation.MyCommand.Path -Parent $projectRoot = (Resolve-Path (Join-Path $scriptDir "../../..")).Path $logDir = Join-Path $projectRoot ".specify/logs" $traceDir = Join-Path $logDir "trace" $auditDir = Join-Path $logDir "audit" $auditLog = Join-Path $auditDir "audit.jsonl" foreach ($d in @($traceDir, $auditDir, (Split-Path $OutputFile -Parent))) { if ($d -and !(Test-Path $d)) { New-Item -ItemType Directory -Force -Path $d | Out-Null } } if (!(Test-Path $InputFile)) { Write-Error "GOVERNANCE_DENIED: input file not found: $InputFile" exit 2 } function New-TraceId { try { return [System.Guid]::NewGuid().ToString("D") } catch { return "trace-$(Get-Date -Format 'yyyyMMddHHmmss')-$PID" } } function Get-Sha256 ([string]$text) { $bytes = [System.Text.Encoding]::UTF8.GetBytes($text) $hash = [System.Security.Cryptography.SHA256]::Create().ComputeHash($bytes) return ($hash | ForEach-Object { $_.ToString("x2") }) -join "" } function ConvertTo-JsonArray ([string[]]$arr) { if (!$arr -or $arr.Count -eq 0) { return "[]" } $escaped = $arr | ForEach-Object { '"' + ($_ -replace '"','\"') + '"' } return "[" + ($escaped -join ",") + "]" } $traceId = New-TraceId $timestamp = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ") $input = Get-Content $InputFile -Raw -Encoding UTF8 if (!$input) { $input = "" } $lowerInput = $input.ToLower() $actor = if ($env:CASAN_ACTOR) { $env:CASAN_ACTOR } else { "developer" } $approver = if ($env:CASAN_APPROVER) { $env:CASAN_APPROVER } else { "" } $approvalDecision = if ($env:CASAN_APPROVAL_DECISION) { $env:CASAN_APPROVAL_DECISION } else { "auto" } $agentName = if ($env:CASAN_AGENT_NAME) { $env:CASAN_AGENT_NAME } else { "unknown" } $riskLevel = "low" $reasons = [System.Collections.Generic.List[string]]::new() # ── Risk by action name ──────────────────────────────────────────────────── switch -Regex ($ActionName) { "^(write_code|write_file|external_api|tool_call)$" { $riskLevel = "medium"; $reasons.Add("sensitive-action:$ActionName") } "^(deploy|launch|migration|db_write)$" { $riskLevel = "high"; $reasons.Add("high-risk-action:$ActionName") } } # ── Tool registry agent whitelist check ─────────────────────────────────── if ($agentName -ne "unknown") { if ($agentName -match "okr\.(srs|bd|reviewspec|reviewplan|reviewcode)" -or $agentName -match "speckit\.(specify|clarify|plan|tasks)") { if ($ActionName -match "^(deploy|migration|db_write)$") { $riskLevel = "high" $reasons.Add("unauthorized-action-for-agent:$ActionName") } } } # ── Risk by content keywords ─────────────────────────────────────────────── if ($lowerInput -match "(delete|drop table|password|api[_-]?key|secret|token|credential|migration|deploy|external api|shutdown|dump database)") { $riskLevel = "high" if (!$reasons.Contains("high-risk-content")) { $reasons.Add("high-risk-content") } } elseif ($lowerInput -match "(internal|config|system|policy|permission)") { if ($riskLevel -eq "low") { $riskLevel = "medium"; $reasons.Add("medium-risk-content") } } # ── Approval decision ────────────────────────────────────────────────────── $approvalStatus = "auto_approved" $decision = "approved" if ($riskLevel -eq "medium") { $approvalStatus = "policy_auto_approved_with_audit" } if ($riskLevel -eq "high") { if ($approvalDecision -eq "approve" -and $approver -ne "") { $approvalStatus = "human_approved"; $decision = "approved" } else { $approvalStatus = "approval_required"; $decision = "denied" } } # ── Hash + chain ─────────────────────────────────────────────────────────── $inputHash = Get-Sha256 $input $prevHash = "" if (Test-Path $auditLog) { $lastLine = Get-Content $auditLog -Tail 1 if ($lastLine -match '"record_hash":"([^"]+)"') { $prevHash = $Matches[1] } } $recordCore = "$timestamp|$traceId|$ActionName|$actor|$riskLevel|$decision|$approvalStatus|$inputHash|$prevHash" $recordHash = Get-Sha256 $recordCore $reasonsJson = ConvertTo-JsonArray ($reasons.ToArray()) # ── Trace JSON ───────────────────────────────────────────────────────────── $traceFile = Join-Path $traceDir "governance-$traceId.json" @" { "trace_id": "$traceId", "timestamp": "$timestamp", "harness": "H5-governance", "action": "$ActionName", "actor": "$actor", "risk_level": "$riskLevel", "decision": "$decision", "approval_status": "$approvalStatus", "approver": "$approver", "reasons": $reasonsJson, "input_hash": "$inputHash", "previous_record_hash": "$prevHash", "record_hash": "$recordHash" } "@ | Set-Content -Path $traceFile -Encoding UTF8 # ── Audit JSONL (append-only) ────────────────────────────────────────────── $auditLine = "{`"timestamp`":`"$timestamp`",`"trace_id`":`"$traceId`",`"harness`":`"H5-governance`",`"action`":`"$ActionName`",`"actor`":`"$actor`",`"risk_level`":`"$riskLevel`",`"decision`":`"$decision`",`"approval_status`":`"$approvalStatus`",`"approver`":`"$approver`",`"input_hash`":`"$inputHash`",`"previous_record_hash`":`"$prevHash`",`"record_hash`":`"$recordHash`"}" Add-Content -Path $auditLog -Value $auditLine -Encoding UTF8 # ── Result ───────────────────────────────────────────────────────────────── if ($decision -ne "approved") { Set-Content -Path $OutputFile -Value "" -Encoding UTF8 Write-Error "GOVERNANCE_DENIED trace_id=$traceId risk=$riskLevel approval_status=$approvalStatus" exit 2 } $input | Set-Content -Path $OutputFile -Encoding UTF8 Write-Output "GOVERNANCE_APPROVED trace_id=$traceId risk=$riskLevel approval_status=$approvalStatus output=$OutputFile" exit 0