Files
CASAN/packages/casan-harness/scripts/powershell/governance-check.ps1
T
thanhnvandClaude Opus 4.8 36a4812ef3 refactor(structure): promote app to repo root + remove redundant workspace cruft
Standard production layout: the OKR app (was nested under AINative_OKR_CASAN5/) is now
the repository root. No more wrapper directory.

- Promote AINative_OKR_CASAN5/* -> repo root (backend/ frontend/ packages/ apps/
  .specify/ docs/ infra/ nginx/ scripts/ + configs). Merge tool dirs: .gitea (kept the
  active deploy ci.yml, added harness-ci.yml + runbooks), .claude (agents/commands +
  launch.json), .github moved up.
- Remove redundant: 00_SUBMISSION_PACKAGE, scattered root notes (FPT_CASAN_Full.md,
  tu-tuong-casan.md, casan-tu-sinh..., casan_harness_assessment.md, source-review...,
  README_CASAN5_REFINED.md), casan-next-plans/ and optimize-docs/ (competition/planning
  artifacts — roadmap + design history preserved in git log / commit messages).
- Update all references to the old layout:
  - .gitea/workflows/{ci,harness-ci}.yml, .github/workflows/{ci,deploy}.yml:
    working-directory .; drop AINative_OKR_CASAN5/ prefix; .specify/{tests,scripts}
    -> packages/casan-harness/... (.specify/logs state kept)
  - .claude/launch.json, .gitea/*-runbook.md: path prefixes
  - CLAUDE.md, README.md: docs/input -> apps/okr/domain/input
  - policy-bundle.yaml: 8 policy paths -> packages/casan-harness/...; manifest re-signed
- secrets-scan.sh: fixture excludes -> new package/domain paths.

Full gate from the new root: PASS=64 FAIL=0 SKIP=3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 13:26:36 +09:00

150 lines
7.0 KiB
PowerShell

#!/usr/bin/env pwsh
# CASAN H5 Governance Harness - PowerShell port of governance-check.sh
# Usage:
# governance-check.ps1 <input-file> <output-file> [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