Files
CASAN/packages/casan-harness/scripts/bash/rai-guard.py
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

167 lines
6.0 KiB
Python

#!/usr/bin/env python3
"""CASAN Responsible AI & Data Governance guard (Plan-15 core, harness-owned).
Reusable governance asset in the core harness. Three deterministic controls:
classify label text by data sensitivity (PII / confidential / internal / public)
check-cloud DENY sending PII/confidential to a cloud model without approval (extends C3)
model-card require an approved model card (source/version/role/risks) — block uncarded
Exit codes: 0 = OK/ALLOW, 1 = DENY/BLOCK. Reasons on stderr.
"""
import argparse
import json
import os
import re
import sys
import time
PII_PATTERNS = [
(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}", "email"),
(r"\b(?:\d[ -]?){13,16}\b", "card-number"),
(r"\b\d{3}-\d{2}-\d{4}\b", "ssn"),
(r"\b(?:sk-[A-Za-z0-9]{16,}|AKIA[0-9A-Z]{12,}|ghp_[A-Za-z0-9]{20,})\b", "secret-token"),
(r"(?i)\bpassword\s*[:=]\s*\S+", "password"),
(r"(?i)\b(?:\+?\d[\d -]{8,}\d)\b", "phone"),
]
CONFIDENTIAL_PATTERNS = [
(r"(?i)\b(confidential|internal only|top secret|restricted)\b", "marker"),
]
SENSITIVE_LABELS = {"PII", "confidential"}
def classify(text: str):
pii = [name for pat, name in PII_PATTERNS if re.search(pat, text)]
if pii:
return "PII", pii
conf = [name for pat, name in CONFIDENTIAL_PATTERNS if re.search(pat, text)]
if conf:
return "confidential", conf
return "internal", []
def read_input(path: str) -> str:
return sys.stdin.read() if path == "-" else open(path, encoding="utf-8").read()
def cmd_classify(args) -> int:
label, matches = classify(read_input(args.input))
print(f"RAI_CLASSIFY label={label} matches={','.join(matches) if matches else '-'}")
return 0
def cmd_check_cloud(args) -> int:
label, matches = classify(read_input(args.input))
if args.target == "cloud" and label in SENSITIVE_LABELS and not (args.approval or "").strip():
print(f"RAI_DENY DATA_TO_CLOUD label={label} matches={','.join(matches)}", file=sys.stderr)
return 1
print(f"RAI_ALLOW target={args.target} label={label}")
return 0
REQUIRED_CARD_FIELDS = ["source", "role", "risks"]
def cmd_model_card(args) -> int:
try:
cards = json.load(open(args.cards, encoding="utf-8"))
except (OSError, ValueError):
print(f"RAI_DENY MODEL_CARDS_UNREADABLE {args.cards}", file=sys.stderr)
return 1
card = cards.get(args.model)
if card is None:
print(f"RAI_DENY MODEL_UNCARDED {args.model}", file=sys.stderr)
return 1
missing = [f for f in REQUIRED_CARD_FIELDS if not card.get(f)]
if "version" not in card and "digest" not in card:
missing.append("version|digest")
if missing:
print(f"RAI_DENY MODEL_CARD_INCOMPLETE {args.model} missing={','.join(missing)}", file=sys.stderr)
return 1
print(f"RAI_ALLOW MODEL_CARDED {args.model}")
return 0
def read_items(path):
items = []
with open(path, encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if line:
try:
items.append(json.loads(line))
except ValueError:
continue
return items
def cmd_report(args) -> int:
"""RAI aggregate: distribution of data sensitivity across a set of items."""
dist = {"PII": 0, "confidential": 0, "internal": 0}
for item in read_items(args.items):
label, _ = classify(str(item.get("text", "")))
dist[label] = dist.get(label, 0) + 1
print(json.dumps({"distribution": dist, "total": sum(dist.values())}, ensure_ascii=False))
return 0
def cmd_retention(args) -> int:
"""Data retention: flag items older than the policy; purge writes an audit
record. --gate fails when expired items remain un-purged (retention breach)."""
now = args.now if args.now is not None else int(time.time())
items = read_items(args.items)
expired = [i for i in items if (now - int(i.get("created_epoch", now))) / 86400.0 > args.days]
purged = 0
if args.purge and expired:
audit_file = args.audit or os.environ.get("CASAN_RAI_AUDIT", "")
if audit_file:
os.makedirs(os.path.dirname(os.path.abspath(audit_file)), exist_ok=True)
with open(audit_file, "a", encoding="utf-8") as fh:
for i in expired:
fh.write(json.dumps({"id": i.get("id"), "purged_at": now, "reason": "retention"}, ensure_ascii=False) + "\n")
purged = len(expired)
remaining = 0 if args.purge else len(expired)
print(f"RAI_RETENTION total={len(items)} expired={len(expired)} purged={purged} remaining_expired={remaining}")
if args.gate and remaining > 0:
print(f"RAI_DENY RETENTION_BREACH expired_unpurged={remaining}", file=sys.stderr)
return 1
return 0
def main() -> int:
ap = argparse.ArgumentParser()
sub = ap.add_subparsers(dest="cmd", required=True)
c = sub.add_parser("classify"); c.add_argument("--input", default="-")
cc = sub.add_parser("check-cloud")
cc.add_argument("--input", default="-"); cc.add_argument("--target", choices=["cloud", "local"], required=True)
cc.add_argument("--approval", default="")
mc = sub.add_parser("model-card")
mc.add_argument("--model", required=True); mc.add_argument("--cards", required=True)
rp = sub.add_parser("report"); rp.add_argument("--items", required=True)
rt = sub.add_parser("retention")
rt.add_argument("--items", required=True)
rt.add_argument("--days", type=int, required=True)
rt.add_argument("--now", type=int, default=None)
rt.add_argument("--purge", action="store_true")
rt.add_argument("--audit", default="")
rt.add_argument("--gate", action="store_true")
args = ap.parse_args()
if args.cmd == "classify":
return cmd_classify(args)
if args.cmd == "check-cloud":
return cmd_check_cloud(args)
if args.cmd == "model-card":
return cmd_model_card(args)
if args.cmd == "report":
return cmd_report(args)
if args.cmd == "retention":
return cmd_retention(args)
return 2
if __name__ == "__main__":
raise SystemExit(main())