#!/usr/bin/env bash set -euo pipefail # CASAN H5 Governance Harness # Usage: # governance-check.sh [action-name] # # Non-interactive by default. High-risk actions are denied unless: # CASAN_APPROVAL_DECISION=approve CASAN_APPROVER= INPUT_FILE="${1:-}" OUTPUT_FILE="${2:-}" ACTION_NAME="${3:-agent_step}" if [[ -z "$INPUT_FILE" || -z "$OUTPUT_FILE" ]]; then echo "Usage: governance-check.sh [action-name]" >&2 exit 64 fi SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" LOG_DIR="$PROJECT_ROOT/.specify/logs" TRACE_DIR="$LOG_DIR/trace" AUDIT_DIR="$LOG_DIR/audit" mkdir -p "$TRACE_DIR" "$AUDIT_DIR" "$(dirname "$OUTPUT_FILE")" if [[ ! -f "$INPUT_FILE" ]]; then echo "GOVERNANCE_DENIED: input file not found: $INPUT_FILE" >&2 exit 1 fi timestamp() { date -u +"%Y-%m-%dT%H:%M:%SZ" } new_trace_id() { if command -v uuidgen >/dev/null 2>&1; then uuidgen | tr '[:upper:]' '[:lower:]' else printf 'trace-%s-%s\n' "$(date +%s)" "$$" fi } hash_text() { if command -v sha256sum >/dev/null 2>&1; then sha256sum | awk '{print $1}' else shasum -a 256 | awk '{print $1}' fi } json_escape() { python -c 'import json,sys; print(json.dumps(sys.stdin.read()))' 2>/dev/null || sed 's/\\/\\\\/g; s/"/\\"/g' } TRACE_ID="$(new_trace_id)" TIMESTAMP="$(timestamp)" INPUT="$(cat "$INPUT_FILE")" LOWER_INPUT="$(printf '%s' "$INPUT" | tr '[:upper:]' '[:lower:]')" ACTOR="${CASAN_ACTOR:-developer}" APPROVER="${CASAN_APPROVER:-}" APPROVAL_DECISION="${CASAN_APPROVAL_DECISION:-auto}" AUDIT_LOG="$AUDIT_DIR/audit.jsonl" RISK_LEVEL="low" REASONS=() case "$ACTION_NAME" in deploy|launch|write_code|write_file|migration|db_write|external_api|tool_call) RISK_LEVEL="medium" REASONS+=("sensitive-action:$ACTION_NAME") ;; esac if printf '%s' "$LOWER_INPUT" | grep -Eq "(delete|drop table|password|api[_-]?key|secret|token|credential|migration|deploy|external api|shutdown|dump database)"; then RISK_LEVEL="high" REASONS+=("high-risk-content") elif printf '%s' "$LOWER_INPUT" | grep -Eq "(internal|config|system|policy|permission)"; then [[ "$RISK_LEVEL" == "low" ]] && RISK_LEVEL="medium" REASONS+=("medium-risk-content") fi APPROVAL_STATUS="auto_approved" DECISION="approved" if [[ "$RISK_LEVEL" == "medium" ]]; then APPROVAL_STATUS="policy_auto_approved_with_audit" fi if [[ "$RISK_LEVEL" == "high" ]]; then if [[ "${CASAN_APPROVAL_STRICT:-0}" == "1" ]]; then # Approval-identity mode (V20): an env-var approver is NOT enough — the # reviewer must cryptographically SIGN this exact request and their role must # be authorized for the action. SoD (actor != approver) still enforced. if [[ "$APPROVAL_DECISION" == "approve" && -n "$APPROVER" && ( -n "${CASAN_APPROVAL_SIG:-}" || -n "${CASAN_APPROVAL_JWT:-}" ) ]]; then if [[ "$APPROVER" == "$ACTOR" ]]; then APPROVAL_STATUS="separation_of_duties_violation" DECISION="denied" REASONS+=("separation-of-duties:actor-equals-approver") else AV_RC=0 AV_OUT="$(bash "$SCRIPT_DIR/approval-verify.sh" "$ACTION_NAME" "$ACTOR" "$INPUT_FILE" "$APPROVER" "${CASAN_APPROVAL_SIG:-"-"}" 2>/dev/null)" || AV_RC=$? if [[ "$AV_RC" -eq 0 ]]; then if printf '%s' "$AV_OUT" | grep -q "mechanism=oidc"; then APPROVAL_STATUS="human_approved_oidc" else APPROVAL_STATUS="human_approved_signed" fi DECISION="approved" REASONS+=("signed-approval:${AV_OUT#APPROVAL_OK }") else APPROVAL_STATUS="approval_signature_invalid" DECISION="denied" REASONS+=("signed-approval-failed") fi fi else APPROVAL_STATUS="approval_required_signed" DECISION="denied" REASONS+=("strict-requires-signed-approval") fi elif [[ "$APPROVAL_DECISION" == "approve" && -n "$APPROVER" ]]; then if [[ "$APPROVER" == "$ACTOR" ]]; then # Separation of duties: the submitter may not approve their own action. APPROVAL_STATUS="separation_of_duties_violation" DECISION="denied" REASONS+=("separation-of-duties:actor-equals-approver") else APPROVAL_STATUS="human_approved" DECISION="approved" fi else APPROVAL_STATUS="approval_required" DECISION="denied" fi fi INPUT_HASH="$(printf '%s' "$INPUT" | hash_text)" OUTPUT_CONTENT="$INPUT" OUTPUT_HASH="$(printf '%s' "$OUTPUT_CONTENT" | hash_text)" PREV_HASH="" if [[ -s "$AUDIT_LOG" ]]; then PREV_HASH="$(tail -n 1 "$AUDIT_LOG" | sed -n 's/.*"record_hash":"\([^"]*\)".*/\1/p')" fi REASONS_JSON="$(printf '%s\n' "${REASONS[@]:-}" | python -c 'import json,sys; print(json.dumps([x for x in sys.stdin.read().splitlines() if x]))')" # approver and output_hash are part of the hashed core so they cannot be # silently mutated after the fact. RECORD_CORE="$(printf '%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s' "$TIMESTAMP" "$TRACE_ID" "$ACTION_NAME" "$ACTOR" "$RISK_LEVEL" "$DECISION" "$APPROVAL_STATUS" "$APPROVER" "$INPUT_HASH" "$OUTPUT_HASH" "$PREV_HASH")" RECORD_HASH="$(printf '%s' "$RECORD_CORE" | hash_text)" TRACE_FILE="$TRACE_DIR/governance-$TRACE_ID.json" cat > "$TRACE_FILE" <> "$AUDIT_LOG" # --- External anchor: cryptographically sign the new chain head --- # A re-forged chain (recomputed hashes) changes the head; without the private # key the attacker cannot produce a matching signature, so verification fails. # Production note: the private key must live off-repo (KMS/HSM). It is local # here only for self-contained demonstration. if command -v openssl >/dev/null 2>&1; then # Private signing key lives OFF-REPO (default ~/.casan/audit-keys); only the # public key is committed. Production: replace with KMS/HSM. PUB_DIR="$PROJECT_ROOT/.specify/level5/central-governance" PRIV_DIR="${CASAN_AUDIT_KEY_DIR:-$HOME/.casan/audit-keys}" AUDIT_PRIV="$PRIV_DIR/audit-private.pem" AUDIT_PUB="$PUB_DIR/audit-public.pem" mkdir -p "$PUB_DIR" "$PRIV_DIR" if [[ ! -f "$AUDIT_PRIV" ]]; then openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out "$AUDIT_PRIV" 2>/dev/null chmod 600 "$AUDIT_PRIV" fi # Always re-export the public key so it matches the private key we sign with. # Without this, a private key that PERSISTS on a CI runner drifts out of sync # with a freshly checked-out audit-public.pem (e.g. one committed after a # Vault-KMS signing), and verify-audit-chain.sh would reject a genuine head. openssl rsa -in "$AUDIT_PRIV" -pubout -out "$AUDIT_PUB" 2>/dev/null || true printf '%s' "$RECORD_HASH" > "$AUDIT_DIR/audit-head.txt" openssl dgst -sha256 -sign "$AUDIT_PRIV" -out "$AUDIT_DIR/audit-head.sig" "$AUDIT_DIR/audit-head.txt" 2>/dev/null || true fi if [[ "$DECISION" != "approved" ]]; then : > "$OUTPUT_FILE" echo "GOVERNANCE_DENIED trace_id=$TRACE_ID risk=$RISK_LEVEL approval_status=$APPROVAL_STATUS" >&2 exit 2 fi printf '%s\n' "$OUTPUT_CONTENT" > "$OUTPUT_FILE" echo "GOVERNANCE_APPROVED trace_id=$TRACE_ID risk=$RISK_LEVEL approval_status=$APPROVAL_STATUS output=$OUTPUT_FILE"