Under CASAN_APPROVAL_STRICT=1, a high-risk approval is trusted ONLY when a REGISTERED reviewer cryptographically signs THIS exact request and their role is authorized for the action — a plain env-var CASAN_APPROVER is no longer enough. - approval-sign.sh: reviewer signs assertion "casan-approval|v1|<action>|<actor>|<input_sha256>|<approver_id>" with their key. - approval-verify.sh: gate looks up reviewer role+pubkey in reviewers.registry, enforces role→action authorization, verifies the RSA signature (fail-closed). - governance-check.sh: strict branch requires a valid signed approval; SoD still enforced; default (non-strict) env-var path UNCHANGED (baseline preserved). - reviewers.registry: role-scoped reviewer identity registry (pubkeys off-repo; production replaces with OIDC/JWT from a real IdP). - phase-h5-approval-tests.sh: 8 checks — valid/authorized approve; unsigned, wrong-role, forged-key, unregistered, replay-to-other-request, self-approval all denied; non-strict backward-compat. Baselines: run-casan4 35/35, adversarial 44/44. Lifts H5 policy-approval (C4) 2.5 -> ~3.5-4 / 5. Total suites now 6 (+8 checks = 148). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
205 lines
7.6 KiB
Bash
Executable File
205 lines
7.6 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
# CASAN H5 Governance Harness
|
|
# Usage:
|
|
# governance-check.sh <input-file> <output-file> [action-name]
|
|
#
|
|
# Non-interactive by default. High-risk actions are denied unless:
|
|
# CASAN_APPROVAL_DECISION=approve CASAN_APPROVER=<name>
|
|
|
|
INPUT_FILE="${1:-}"
|
|
OUTPUT_FILE="${2:-}"
|
|
ACTION_NAME="${3:-agent_step}"
|
|
|
|
if [[ -z "$INPUT_FILE" || -z "$OUTPUT_FILE" ]]; then
|
|
echo "Usage: governance-check.sh <input-file> <output-file> [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:-}" ]]; 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
|
|
APPROVAL_STATUS="human_approved_signed"
|
|
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" <<EOF
|
|
{
|
|
"trace_id": "$TRACE_ID",
|
|
"timestamp": "$TIMESTAMP",
|
|
"harness": "H5-governance",
|
|
"action": "$ACTION_NAME",
|
|
"actor": "$ACTOR",
|
|
"risk_level": "$RISK_LEVEL",
|
|
"decision": "$DECISION",
|
|
"approval_status": "$APPROVAL_STATUS",
|
|
"approver": "$APPROVER",
|
|
"reasons": $REASONS_JSON,
|
|
"input_hash": "$INPUT_HASH",
|
|
"output_hash": "$OUTPUT_HASH",
|
|
"previous_record_hash": "$PREV_HASH",
|
|
"record_hash": "$RECORD_HASH"
|
|
}
|
|
EOF
|
|
|
|
printf '{"timestamp":"%s","trace_id":"%s","harness":"H5-governance","action":"%s","actor":"%s","risk_level":"%s","decision":"%s","approval_status":"%s","approver":"%s","input_hash":"%s","output_hash":"%s","previous_record_hash":"%s","record_hash":"%s"}\n' \
|
|
"$TIMESTAMP" "$TRACE_ID" "$ACTION_NAME" "$ACTOR" "$RISK_LEVEL" "$DECISION" "$APPROVAL_STATUS" "$APPROVER" "$INPUT_HASH" "$OUTPUT_HASH" "$PREV_HASH" "$RECORD_HASH" >> "$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"
|