feat: update plan 17
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CASAN Loop Trace / Replay (Plan-17 Track 4, harness-owned).
|
||||
|
||||
Records each loop iteration (§3 Loop Contract) into a per-run, append-only,
|
||||
hash-linked trace so a whole loop can be audited and deterministically replayed.
|
||||
Gives loops the "loop view" the audit chain lacked.
|
||||
|
||||
Subcommands:
|
||||
record append one Iteration to the run's trace (hash-linked to the prev).
|
||||
show render the loop view (JSON on stdout; a human table on stderr).
|
||||
replay re-run the per-iteration Verify Contract (loop-gate) on each
|
||||
recorded artifact and compare the fresh verdict to the recorded
|
||||
one -> detects non-determinism / tampered artifacts.
|
||||
verify-chain recompute the hash chain -> any edited/removed record BREAKs it.
|
||||
|
||||
Fail-closed & tail-safe (Plan-17 §2): the trace is append-only; replay never
|
||||
mutates the source trace (it runs loop-gate under throwaway run-ids). A corrupt
|
||||
trace file fails closed (verify-chain BREAK / show error).
|
||||
|
||||
KMS-anchoring of the chain head (17.16 full) is a TIER-2 add-on (Plan-07 B3 / A7);
|
||||
this offline slice proves local tamper-evidence without it.
|
||||
|
||||
Usage:
|
||||
loop-trace.py record --run-id R --step N --intent ... --action ... \
|
||||
[--tool T] [--inputs-ref REF] [--gate-verdict PASS|FAIL|DENY|ESCALATE] \
|
||||
[--progress P] [--decision CONTINUE|HALT|ESCALATE|DONE] \
|
||||
[--artifact PATH] [--success-criteria FILE] [--evidence-ref REF] \
|
||||
[--budget-snapshot JSON]
|
||||
loop-trace.py show --run-id R [--json]
|
||||
loop-trace.py replay --run-id R [--profile prod|dev]
|
||||
loop-trace.py verify-chain --run-id R
|
||||
|
||||
Exit codes:
|
||||
0 ok / chain intact / replay all-match
|
||||
3 chain BREAK, replay drift detected, or fail-closed error
|
||||
2 usage error
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import loop_common as lc
|
||||
|
||||
|
||||
def _emit(payload, stream=sys.stdout):
|
||||
json.dump(payload, stream, ensure_ascii=False, indent=2, sort_keys=True)
|
||||
stream.write("\n")
|
||||
|
||||
|
||||
def _trace_path(run_id):
|
||||
return os.path.join(lc.run_dir(run_id), "trace.jsonl")
|
||||
|
||||
|
||||
def _read_entries(run_id):
|
||||
"""Return the list of raw chain entries (each {seq, prev_hash, iteration, hash}).
|
||||
A malformed line fails closed."""
|
||||
path = _trace_path(run_id)
|
||||
entries = []
|
||||
if os.path.isfile(path):
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
entries.append(json.loads(line))
|
||||
except ValueError:
|
||||
raise lc.PolicyError("trace_corrupt")
|
||||
return entries
|
||||
|
||||
|
||||
def cmd_record(args):
|
||||
budget = None
|
||||
if args.budget_snapshot:
|
||||
try:
|
||||
budget = json.loads(args.budget_snapshot)
|
||||
except ValueError:
|
||||
raise lc.PolicyError("bad_budget_snapshot_json")
|
||||
|
||||
iteration = {
|
||||
"run_id": args.run_id,
|
||||
"step": args.step,
|
||||
"intent": args.intent,
|
||||
"action": args.action,
|
||||
"tool": args.tool,
|
||||
"inputs_ref": args.inputs_ref,
|
||||
"gate_verdict": args.gate_verdict,
|
||||
"progress": args.progress,
|
||||
"budget_snapshot": budget,
|
||||
"decision": args.decision,
|
||||
"artifact": args.artifact,
|
||||
"success_criteria": args.success_criteria,
|
||||
"evidence_ref": args.evidence_ref,
|
||||
"ts": lc.now_iso(),
|
||||
}
|
||||
|
||||
path = _trace_path(args.run_id)
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
entries = _read_entries(args.run_id)
|
||||
prev = entries[-1]["hash"] if entries else lc.GENESIS_HASH
|
||||
seq = len(entries)
|
||||
base = {"seq": seq, "prev_hash": prev, "iteration": iteration}
|
||||
base["hash"] = lc.chain_hash(base)
|
||||
with open(path, "a", encoding="utf-8") as fh:
|
||||
fh.write(lc.canonical(base) + "\n")
|
||||
_emit({"decision": "RECORDED", "run_id": args.run_id, "step": args.step, "seq": seq})
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_show(args):
|
||||
entries = _read_entries(args.run_id)
|
||||
iterations = [e["iteration"] for e in entries]
|
||||
payload = {
|
||||
"run_id": args.run_id,
|
||||
"count": len(iterations),
|
||||
"iterations": iterations,
|
||||
"provenance": lc.provenance("loop-trace", _trace_path(args.run_id), verified=False),
|
||||
}
|
||||
# Human-readable loop view on stderr; machine JSON on stdout.
|
||||
hdr = f"{'seq':>3} {'step':>4} {'verdict':<9} {'decision':<10} {'progress':>8} intent"
|
||||
print(hdr, file=sys.stderr)
|
||||
for e in entries:
|
||||
it = e["iteration"]
|
||||
print(
|
||||
f"{e['seq']:>3} {str(it.get('step','')):>4} "
|
||||
f"{str(it.get('gate_verdict','')):<9} {str(it.get('decision','')):<10} "
|
||||
f"{str(it.get('progress','')):>8} {str(it.get('intent',''))[:48]}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
_emit(payload)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_verify_chain(args):
|
||||
try:
|
||||
entries = _read_entries(args.run_id)
|
||||
except lc.PolicyError as exc:
|
||||
_emit({"decision": "BREAK", "reason": "fail_closed", "detail": str(exc), "run_id": args.run_id})
|
||||
return 3
|
||||
prev = lc.GENESIS_HASH
|
||||
for i, e in enumerate(entries):
|
||||
stored = e.get("hash")
|
||||
base = {"seq": e.get("seq"), "prev_hash": e.get("prev_hash"), "iteration": e.get("iteration")}
|
||||
recomputed = lc.chain_hash(base)
|
||||
if e.get("prev_hash") != prev or e.get("seq") != i or recomputed != stored:
|
||||
_emit({
|
||||
"decision": "BREAK", "reason": "chain_broken", "at_seq": i,
|
||||
"run_id": args.run_id,
|
||||
"provenance": lc.provenance("loop-trace", _trace_path(args.run_id), verified=False),
|
||||
})
|
||||
return 3
|
||||
prev = stored
|
||||
_emit({
|
||||
"decision": "OK", "run_id": args.run_id, "records": len(entries),
|
||||
"provenance": lc.provenance("loop-trace", _trace_path(args.run_id), verified=True),
|
||||
})
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_replay(args):
|
||||
try:
|
||||
entries = _read_entries(args.run_id)
|
||||
except lc.PolicyError as exc:
|
||||
_emit({"decision": "FAIL", "reason": "fail_closed", "detail": str(exc), "run_id": args.run_id})
|
||||
return 3
|
||||
|
||||
gate = os.path.join(os.path.dirname(__file__), "loop-gate.py")
|
||||
diffs = []
|
||||
replayed = 0
|
||||
for e in entries:
|
||||
it = e["iteration"]
|
||||
artifact = it.get("artifact")
|
||||
recorded = it.get("gate_verdict")
|
||||
if not artifact or not recorded:
|
||||
continue # only iterations with a verifiable artifact + verdict
|
||||
replayed += 1
|
||||
cmd = [
|
||||
sys.executable, gate, "verify",
|
||||
"--run-id", f"{args.run_id}-replay-{e['seq']}",
|
||||
"--step", str(it.get("step", 0)),
|
||||
"--artifact", artifact,
|
||||
]
|
||||
if it.get("success_criteria"):
|
||||
cmd += ["--success-criteria", it["success_criteria"]]
|
||||
if args.profile:
|
||||
cmd += ["--profile", args.profile]
|
||||
try:
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=90)
|
||||
fresh = json.loads(r.stdout).get("verdict") if r.stdout.strip() else "ERROR"
|
||||
except Exception as exc:
|
||||
fresh = f"ERROR:{exc}"
|
||||
if fresh != recorded:
|
||||
diffs.append({"seq": e["seq"], "step": it.get("step"), "recorded": recorded, "replayed": fresh})
|
||||
|
||||
payload = {
|
||||
"run_id": args.run_id,
|
||||
"replayed": replayed,
|
||||
"diffs": diffs,
|
||||
"provenance": lc.provenance("loop-trace", _trace_path(args.run_id), verified=not diffs),
|
||||
}
|
||||
if diffs:
|
||||
payload["decision"] = "DRIFT"
|
||||
payload["reason"] = "verdict_mismatch"
|
||||
lc.append_audit({"kind": "trace_replay_drift", **payload})
|
||||
_emit(payload)
|
||||
return 3
|
||||
payload["decision"] = "MATCH"
|
||||
_emit(payload)
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser():
|
||||
p = argparse.ArgumentParser(description="CASAN Loop Trace / Replay (Plan-17 T4)")
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
r = sub.add_parser("record", help="Append one Iteration to the run trace")
|
||||
r.add_argument("--run-id", required=True)
|
||||
r.add_argument("--step", type=int, required=True)
|
||||
r.add_argument("--intent", default=None)
|
||||
r.add_argument("--action", default=None)
|
||||
r.add_argument("--tool", default=None)
|
||||
r.add_argument("--inputs-ref", default=None)
|
||||
r.add_argument("--gate-verdict", default=None)
|
||||
r.add_argument("--progress", type=float, default=None)
|
||||
r.add_argument("--decision", default=None)
|
||||
r.add_argument("--artifact", default=None)
|
||||
r.add_argument("--success-criteria", default=None)
|
||||
r.add_argument("--evidence-ref", default=None)
|
||||
r.add_argument("--budget-snapshot", default=None)
|
||||
r.set_defaults(func=cmd_record)
|
||||
|
||||
s = sub.add_parser("show", help="Render the loop view")
|
||||
s.add_argument("--run-id", required=True)
|
||||
s.add_argument("--json", action="store_true")
|
||||
s.set_defaults(func=cmd_show)
|
||||
|
||||
rp = sub.add_parser("replay", help="Deterministically re-verify recorded artifacts")
|
||||
rp.add_argument("--run-id", required=True)
|
||||
rp.add_argument("--profile", default=None)
|
||||
rp.set_defaults(func=cmd_replay)
|
||||
|
||||
vc = sub.add_parser("verify-chain", help="Recompute the hash chain (tamper-evidence)")
|
||||
vc.add_argument("--run-id", required=True)
|
||||
vc.set_defaults(func=cmd_verify_chain)
|
||||
return p
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
args = build_parser().parse_args(argv)
|
||||
try:
|
||||
return args.func(args)
|
||||
except lc.PolicyError as exc:
|
||||
_emit({"decision": "FAIL", "reason": "fail_closed", "detail": str(exc)})
|
||||
return 3
|
||||
except Exception as exc: # never fail open
|
||||
_emit({"decision": "FAIL", "reason": "internal_error", "detail": str(exc)}, sys.stderr)
|
||||
return 3
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user