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>
150 lines
7.0 KiB
PowerShell
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
|