#!/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())