#!/usr/bin/env python3 """Plan-18 Track 8 chat replay / verify-chain. Verifies chat history after the fact: * H5 chat turn hash-chain is intact, * recorded evidence artifact hashes still match, * OPERATOR loop-run traces still verify and replay through Plan-17 loop-trace. This complements chat-readonly.py verify-audit by validating artifact state, not only the append-only JSONL chain. """ import argparse import hashlib import json import os import subprocess import sys GENESIS_HASH = "0" * 64 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() BIN = os.path.join(ROOT, "packages", "casan-harness", "scripts", "bash") LOOP_TRACE = os.path.join(BIN, "loop-trace.py") TENANT_STORE = os.path.join(BIN, "tenant-store.sh") def state_root() -> str: return os.environ.get("CASAN_STATE_ROOT") or os.path.join(ROOT, ".specify") def tenant_path(logical: str, fallback: str) -> str: if os.environ.get("CASAN_TENANT_ID"): r = subprocess.run(["bash", TENANT_STORE, "resolve", logical], cwd=ROOT, capture_output=True, text=True) if r.returncode != 0: raise SystemExit((r.stderr or r.stdout or "TENANT_DENIED").strip()) return r.stdout.strip() return os.path.join(state_root(), fallback) def guarded_override(path: str) -> str: if path and os.environ.get("CASAN_TENANT_ID"): r = subprocess.run(["bash", TENANT_STORE, "guard", path], cwd=ROOT, capture_output=True, text=True) if r.returncode != 0: raise SystemExit((r.stderr or r.stdout or "TENANT_DENIED").strip()) return path def audit_path() -> str: return guarded_override(os.environ["CASAN_CHAT_AUDIT_LOG"]) if os.environ.get("CASAN_CHAT_AUDIT_LOG") else tenant_path("chat/chat-turns.jsonl", "logs/chat/chat-turns.jsonl") def sha_text(text: str) -> str: return hashlib.sha256(text.encode("utf-8")).hexdigest() def sha_file(path: str) -> str: h = hashlib.sha256() with open(path, "rb") as fh: for chunk in iter(lambda: fh.read(65536), b""): h.update(chunk) return h.hexdigest() def load_records(): records = [] try: with open(audit_path(), encoding="utf-8") as fh: for line in fh: if line.strip(): records.append(json.loads(line)) except OSError: return [] return records def verify_chain(records): prev = GENESIS_HASH for idx, rec in enumerate(records, start=1): 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_text(json.dumps(rest, sort_keys=True, ensure_ascii=False)) != got: return False, idx prev = got return True, None def resolve_path(path: str) -> str: if os.path.isabs(path): return path return os.path.join(ROOT, path) def loop_state_root(loop_run): explicit = os.environ.get("CASAN_LOOP_STATE_ROOT") if explicit: return guarded_override(explicit) artifact = loop_run.get("artifact") or "" marker = os.sep + "logs" + os.sep + "chat" + os.sep + "loop-runs" + os.sep if marker in artifact: return artifact.split(marker, 1)[0] + os.sep + "logs" + os.sep + "chat" + os.sep + "loop-state" return tenant_path("chat/loop-state", "logs/chat/loop-state") def run_loop_trace(loop_run, cmd: str): env = {**os.environ, "CASAN_LOOP_STATE_ROOT": loop_state_root(loop_run)} run_id = loop_run.get("run_id") if not run_id: return False, "missing_loop_run_id" args = ["python3", LOOP_TRACE, cmd, "--run-id", run_id] if cmd == "replay": args += ["--profile", os.environ.get("CASAN_PROFILE", "dev")] r = subprocess.run(args, cwd=ROOT, capture_output=True, text=True, env=env) return r.returncode == 0, (r.stdout + r.stderr).strip() def filter_records(records, chat_id="", turn_id=""): out = records if chat_id: out = [r for r in out if r.get("chat_id") == chat_id] if turn_id: out = [r for r in out if r.get("turn_id") == turn_id] return out def replay(args) -> int: records = load_records() ok, broken_at = verify_chain(records) if not ok: print(json.dumps({"decision": "BREAK", "reason": "chat_chain_broken", "broken_at": broken_at}, ensure_ascii=False)) return 3 selected = filter_records(records, args.chat_id, args.turn_id) diffs = [] replayed = 0 for rec in selected: for src in rec.get("sources", []): expected = src.get("hash") if not expected: continue path = resolve_path(src.get("path", "")) if not os.path.isfile(path): diffs.append({"seq": rec.get("seq"), "kind": "artifact_missing", "path": src.get("path")}) continue got = sha_file(path) if got != expected: diffs.append({"seq": rec.get("seq"), "kind": "artifact_hash_mismatch", "path": src.get("path"), "expected": expected, "got": got}) loop_run = rec.get("loop_run") or {} if loop_run.get("run_id"): replayed += 1 trace_ok, trace_out = run_loop_trace(loop_run, "verify-chain") replay_ok, replay_out = run_loop_trace(loop_run, "replay") if not trace_ok: diffs.append({"seq": rec.get("seq"), "kind": "loop_trace_break", "run_id": loop_run.get("run_id"), "output": trace_out[:400]}) if not replay_ok: diffs.append({"seq": rec.get("seq"), "kind": "loop_replay_drift", "run_id": loop_run.get("run_id"), "output": replay_out[:400]}) payload = { "decision": "DRIFT" if diffs else "MATCH", "records": len(selected), "loop_replayed": replayed, "diffs": diffs, "audit_path": audit_path(), } print(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True)) return 3 if diffs else 0 def verify(args) -> int: records = load_records() ok, broken_at = verify_chain(records) payload = {"decision": "OK" if ok else "BREAK", "records": len(records), "audit_path": audit_path()} if broken_at: payload["broken_at"] = broken_at print(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True)) return 0 if ok else 3 def main() -> int: ap = argparse.ArgumentParser() sub = ap.add_subparsers(dest="cmd", required=True) r = sub.add_parser("replay") r.add_argument("--chat-id", default="") r.add_argument("--turn-id", default="") r.set_defaults(func=replay) v = sub.add_parser("verify-chain") v.set_defaults(func=verify) args = ap.parse_args() return args.func(args) if __name__ == "__main__": raise SystemExit(main())