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>
167 lines
6.0 KiB
Python
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())
|