Files
CASAN/packages/casan-harness/scripts/bash/governance-check.sh
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

241 lines
9.7 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)"
source "$SCRIPT_DIR/casan-paths.sh"
PROJECT_ROOT="$CASAN_APP_ROOT"
LOG_DIR="$CASAN_STATE_ROOT/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"
# SEC-05 (H-04): serialize both the standalone trace file and the appended audit
# chain line via json.dumps. Previously ACTOR/APPROVER/ACTION_NAME were interpolated
# raw, so a value containing `"` + newline could inject a SECOND forged audit record
# (a fabricated "approved" decision). The record_hash is still computed from
# RECORD_CORE above, so verify-audit-chain.sh recomputes and matches unchanged.
# SEC-29 (X-05): the audit write must FAIL CLOSED. If the audit log cannot be
# written (disk full, read-only, quota), there must be NO governed action without
# its accountability record — deny and empty the output rather than proceed.
if ! CASAN_GC_REASONS="$REASONS_JSON" python - "$TRACE_FILE" "$AUDIT_LOG" \
"$TIMESTAMP" "$TRACE_ID" "$ACTION_NAME" "$ACTOR" "$RISK_LEVEL" "$DECISION" \
"$APPROVAL_STATUS" "$APPROVER" "$INPUT_HASH" "$OUTPUT_HASH" "$PREV_HASH" "$RECORD_HASH" <<'PY'
import json, os, sys
(trace_file, audit_log, ts, trace_id, action, actor, risk, decision,
approval_status, approver, input_hash, output_hash, prev_hash, record_hash) = sys.argv[1:]
try:
reasons = json.loads(os.environ.get("CASAN_GC_REASONS") or "[]")
except ValueError:
reasons = []
rec = {
"timestamp": ts, "trace_id": trace_id, "harness": "H5-governance",
"action": action, "actor": actor, "risk_level": risk, "decision": decision,
"approval_status": approval_status, "approver": approver,
"input_hash": input_hash, "output_hash": output_hash,
"previous_record_hash": prev_hash, "record_hash": record_hash,
}
trace = {**rec, "reasons": reasons}
with open(trace_file, "w", encoding="utf-8") as f:
json.dump(trace, f, indent=2)
f.write("\n")
with open(audit_log, "a", encoding="utf-8") as f:
# Compact separators: the chain line is regex-parsed elsewhere and must match
# the original printf format (no space after ':' / ',').
f.write(json.dumps(rec, separators=(",", ":")) + "\n")
f.flush()
os.fsync(f.fileno())
PY
then
: > "$OUTPUT_FILE" 2>/dev/null || true
echo "GOVERNANCE_DENIED trace_id=$TRACE_ID reason=audit_unwritable (fail-closed: no governed action without an audit record)" >&2
exit 2
fi
# --- 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="$CASAN_GOVERNANCE_ROOT"
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
if [[ "${CASAN_PROFILE:-}" == "prod" || "${CASAN_VERIFY_STRICT:-}" == "1" ]]; then
# SEC-02 (H-02): in enforced mode NEVER auto-generate a local signing key.
# A freshly-minted key next to the data lets any file-writer re-sign a forged
# head. Prod must provision the key out-of-band (KMS/HSM — see sign-audit-head.sh
# Vault path). With no key we skip signing; the head stays unsigned and SEC-01
# strict verification then FAILS CLOSED.
echo "AUDIT_SIGN_SKIPPED_ENFORCED no off-repo/KMS key provisioned; head left unsigned (verify fails closed)" >&2
else
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out "$AUDIT_PRIV" 2>/dev/null
chmod 600 "$AUDIT_PRIV"
fi
fi
if [[ -f "$AUDIT_PRIV" ]]; then
# 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
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"