feat: add governed chat console

This commit is contained in:
thanhnv
2026-07-08 21:14:40 +09:00
parent 3be9970c15
commit 5561bcf864
28 changed files with 2023 additions and 43 deletions
+371
View File
@@ -0,0 +1,371 @@
#!/usr/bin/env python3
"""Plan-18 MVP-0 Ask CASAN read-only evidence assistant.
The primitive is deliberately deterministic: no free command execution, no writes
outside chat audit/H6 telemetry, no model dependency. It reads only whitelisted
CASAN documentation/evidence artifacts and returns answer + sources.
"""
import argparse
import hashlib
import json
import os
import re
import subprocess
import sys
import tempfile
import uuid
from datetime import datetime, timezone
GENESIS_HASH = "0" * 64
STOPWORDS = {
"a", "an", "and", "are", "as", "ask", "cua", "cho", "co", "con", "còn",
"do", "for", "gi", "gì", "hay", "how", "is", "ke", "kế", "la", "là",
"of", "on", "the", "to", "trong", "ve", "về", "what", "with",
}
def project_root() -> str:
d = os.path.abspath(os.path.dirname(__file__))
p = d
while p != os.path.dirname(p):
if os.path.isdir(os.path.join(p, ".specify")) or os.path.isdir(os.path.join(p, "packages/casan-harness")):
return p
p = os.path.dirname(p)
return os.path.abspath(os.path.join(d, "..", "..", ".."))
ROOT = project_root()
HARNESS_BIN = os.path.join(ROOT, "packages", "casan-harness", "scripts", "bash")
ROUTER = os.path.join(HARNESS_BIN, "prompt-mode-router.py")
SECURITY = os.path.join(HARNESS_BIN, "security-check.sh")
def state_root() -> str:
return os.environ.get("CASAN_STATE_ROOT") or os.path.join(ROOT, ".specify")
def audit_path() -> str:
return os.environ.get("CASAN_CHAT_AUDIT_LOG") or os.path.join(state_root(), "logs", "chat", "chat-turns.jsonl")
def head_path() -> str:
return os.environ.get("CASAN_CHAT_AUDIT_HEAD") or os.path.join(state_root(), "logs", "chat", "chat-head.txt")
def metrics_path() -> str:
return os.environ.get("CASAN_CHAT_METRICS_LOG") or os.path.join(state_root(), "logs", "cost", "metrics.jsonl")
def now_iso() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def sha(text: str) -> str:
return hashlib.sha256(text.encode("utf-8")).hexdigest()
def git_commit() -> str:
try:
r = subprocess.run(["git", "rev-parse", "HEAD"], cwd=ROOT, capture_output=True, text=True, timeout=5)
if r.returncode == 0:
return r.stdout.strip()
except Exception:
pass
return "unknown"
def provenance(source: str, path: str, verified=True):
return {
"source": source,
"artifact_path": os.path.relpath(path, ROOT),
"commit": git_commit(),
"run_at": now_iso(),
"verified": bool(verified),
}
def run_security(text: str, mode: str):
with tempfile.TemporaryDirectory() as td:
inp = os.path.join(td, "in.txt")
out = os.path.join(td, "out.txt")
with open(inp, "w", encoding="utf-8") as fh:
fh.write(text)
r = subprocess.run(["bash", SECURITY, inp, out, mode], cwd=ROOT, capture_output=True, text=True)
safe = ""
if os.path.exists(out):
safe = open(out, encoding="utf-8").read()
return r.returncode, safe.strip(), (r.stdout + r.stderr).strip()
def classify(message: str):
r = subprocess.run(["python3", ROUTER, "classify", "--message", message], cwd=ROOT, capture_output=True, text=True)
if r.returncode != 0:
return {"mode": "BLOCK", "risk": "high", "reason": "router_failed", "matched_rules": [r.stderr.strip()]}
try:
return json.loads(r.stdout)
except ValueError:
return {"mode": "BLOCK", "risk": "high", "reason": "router_invalid_json", "matched_rules": []}
def whitelist_roots():
raw = os.environ.get("CASAN_CHAT_CONTEXT_ROOTS")
if raw:
roots = [os.path.abspath(p) for p in raw.split(":") if p]
else:
roots = [
os.path.join(ROOT, "docs", "plans"),
os.path.join(ROOT, "docs", "packaging"),
os.path.join(ROOT, "docs", "output", "casan"),
]
return [r for r in roots if os.path.isdir(r)]
def allowed_file(path: str, roots) -> bool:
ap = os.path.abspath(path)
if not any(ap == root or ap.startswith(root + os.sep) for root in roots):
return False
return os.path.splitext(ap)[1].lower() in {".md", ".txt", ".json", ".jsonl", ".yaml", ".yml"}
def terms(text: str):
raw = re.findall(r"[A-Za-z0-9_\-]{2,}|[À-ỹ]{3,}", text.lower())
return [t for t in raw if t not in STOPWORDS][:20]
def collect_sources(query: str):
roots = whitelist_roots()
qterms = terms(query)
scored = []
for root in roots:
for base, _, files in os.walk(root):
for name in files:
path = os.path.join(base, name)
if not allowed_file(path, roots):
continue
try:
with open(path, encoding="utf-8", errors="ignore") as fh:
lines = fh.readlines()
except OSError:
continue
best = []
for idx, line in enumerate(lines, start=1):
l = line.strip()
if not l:
continue
low = l.lower()
score = sum(1 for t in qterms if t in low)
if score:
best.append((score, idx, l[:260]))
if best:
best.sort(key=lambda x: (-x[0], x[1]))
score, line_no, excerpt = best[0]
path_low = os.path.relpath(path, ROOT).lower()
score += sum(2 for t in qterms if t in path_low)
scored.append((score, path, line_no, excerpt))
scored.sort(key=lambda x: (-x[0], x[1]))
out = []
for score, path, line_no, excerpt in scored[:5]:
out.append({
"path": os.path.relpath(path, ROOT),
"line": line_no,
"excerpt": excerpt,
"score": score,
"envelope": provenance("chat-context-whitelist", path, True),
})
if not out:
fallback = os.path.join(ROOT, "docs", "plans", "CASAN_PLAN_18_CHAT_CONSOLE.md")
if os.path.isfile(fallback):
out.append({
"path": os.path.relpath(fallback, ROOT),
"line": 1,
"excerpt": "# KẾ HOẠCH 18 — Governed Chat Console (Chat-as-Loop qua Control Plane)",
"score": 0,
"envelope": provenance("chat-context-whitelist-fallback", fallback, True),
})
return out
def answer_from_sources(message: str, sources):
if not sources:
return "Khong tim thay nguon trong whitelist evidence/docs nen khong tra loi suy doan."
bullets = []
for s in sources[:3]:
bullets.append(f"- {s['path']}:{s['line']} — {s['excerpt']}")
return "Ask CASAN read-only answer (evidence-backed):\n" + "\n".join(bullets)
def append_jsonl(path: str, rec):
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "a", encoding="utf-8") as fh:
fh.write(json.dumps(rec, ensure_ascii=False) + "\n")
def load_head() -> str:
try:
return open(head_path(), encoding="utf-8").read().strip() or GENESIS_HASH
except OSError:
return GENESIS_HASH
def record_turn(base):
path = audit_path()
os.makedirs(os.path.dirname(path), exist_ok=True)
seq = 1
if os.path.isfile(path):
with open(path, encoding="utf-8") as fh:
seq = sum(1 for line in fh if line.strip()) + 1
prev = load_head()
core = {"seq": seq, **base, "prev_hash": prev}
record_hash = sha(json.dumps(core, sort_keys=True, ensure_ascii=False))
rec = {**core, "record_hash": record_hash}
append_jsonl(path, rec)
os.makedirs(os.path.dirname(head_path()), exist_ok=True)
with open(head_path(), "w", encoding="utf-8") as fh:
fh.write(record_hash + "\n")
return rec
def record_metrics(trace_id: str, message: str, answer: str, status: str, latency_ms: int):
input_tokens = len(message.split())
output_tokens = len(answer.split())
rec = {
"timestamp": now_iso(),
"trace_id": trace_id,
"harness": "H6-agentops",
"agent": "chat.ask-casan",
"step": "ask-casan-readonly",
"status": status,
"exit_code": 0 if status == "success" else 2,
"latency_ms": latency_ms,
"retry_count": 0,
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"total_tokens": input_tokens + output_tokens,
"cost_estimate": 0.0,
"cost_source": "readonly_word_count",
"hallucination_signals": 0,
"alerts": [],
"input_hash": sha(message),
"output_hash": sha(answer),
}
append_jsonl(metrics_path(), rec)
def ask(args):
started = datetime.now(timezone.utc)
trace_id = str(uuid.uuid4())
message = args.message
router = classify(message)
actor = args.actor or "anonymous"
chat_id = args.chat_id or "chat-default"
turn_id = args.turn_id or f"turn-{trace_id[:12]}"
tenant_id = args.tenant or "default"
def finish(decision: str, answer: str, sources=None, safe_message=""):
elapsed = int((datetime.now(timezone.utc) - started).total_seconds() * 1000)
sources = sources or []
rec = record_turn({
"timestamp": now_iso(),
"trace_id": trace_id,
"chat_id": chat_id,
"turn_id": turn_id,
"tenant_id": tenant_id,
"actor": actor,
"mode": router.get("mode", "BLOCK"),
"risk": router.get("risk", "high"),
"decision": decision,
"router": router,
"user_msg_ref": sha(message),
"safe_preview": (safe_message or "")[:180],
"answer_ref": sha(answer),
"sources": [{"path": s.get("path"), "line": s.get("line")} for s in sources],
})
record_metrics(trace_id, safe_message or message, answer, "success" if decision == "ANSWERED" else "failed", elapsed)
return {
"success": decision == "ANSWERED",
"chat_id": chat_id,
"turn_id": turn_id,
"trace_id": trace_id,
"mode": router.get("mode", "BLOCK"),
"risk": router.get("risk", "high"),
"decision": decision,
"answer": answer,
"sources": sources,
"certified": decision == "ANSWERED",
"audit": {"seq": rec["seq"], "record_hash": rec["record_hash"], "head": rec["record_hash"]},
"router": router,
}
if router.get("mode") != "READ_ONLY":
answer = f"Denied by Prompt Router: mode={router.get('mode')} reason={router.get('reason')}"
print(json.dumps(finish("NOT_SUPPORTED" if router.get("mode") == "NOT_SUPPORTED" else "DENIED", answer), ensure_ascii=False))
return 2
rc, safe_input, scan_msg = run_security(message, "input")
if rc != 0:
router["mode"] = "BLOCK"
router["reason"] = "h4_input_denied"
router["matched_rules"] = router.get("matched_rules", []) + [scan_msg]
answer = "Denied by H4 input scan."
print(json.dumps(finish("DENIED", answer), ensure_ascii=False))
return 2
sources = collect_sources(safe_input)
answer = answer_from_sources(safe_input, sources)
rc, safe_answer, scan_msg = run_security(answer, "output")
if rc != 0:
router["mode"] = "BLOCK"
router["reason"] = "h4_output_denied"
router["matched_rules"] = router.get("matched_rules", []) + [scan_msg]
print(json.dumps(finish("DENIED", "Denied by H4 output scan.", sources, safe_input), ensure_ascii=False))
return 2
print(json.dumps(finish("ANSWERED", safe_answer, sources, safe_input), ensure_ascii=False))
return 0
def verify_audit() -> int:
prev = GENESIS_HASH
count = 0
try:
fh = open(audit_path(), encoding="utf-8")
except OSError:
print("CHAT_AUDIT ok=true records=0 head=" + prev)
return 0
with fh:
for line in fh:
if not line.strip():
continue
count += 1
rec = json.loads(line)
got = rec.get("record_hash")
rest = {k: v for k, v in rec.items() if k != "record_hash"}
if rest.get("prev_hash") != prev or sha(json.dumps(rest, sort_keys=True, ensure_ascii=False)) != got:
print(f"CHAT_AUDIT ok=false brokenAt={count}")
return 1
prev = got
print(f"CHAT_AUDIT ok=true records={count} head={prev}")
return 0
def main() -> int:
ap = argparse.ArgumentParser()
sub = ap.add_subparsers(dest="cmd", required=True)
askp = sub.add_parser("ask")
askp.add_argument("--message", required=True)
askp.add_argument("--actor", default="anonymous")
askp.add_argument("--chat-id", default="")
askp.add_argument("--turn-id", default="")
askp.add_argument("--tenant", default="default")
sub.add_parser("verify-audit")
args = ap.parse_args()
if args.cmd == "ask":
return ask(args)
if args.cmd == "verify-audit":
return verify_audit()
return 2
if __name__ == "__main__":
raise SystemExit(main())