#!/usr/bin/env python3
"""Clean-room verifier for the CAIN-42 Prompt 6 adaptive-evolution bundle.

INDEPENDENT IMPLEMENTATION: imports NOTHING from CAIN (standard library + `cryptography` only). It re-derives,
from the signed records:
  - every proposal signature (registered agent key) and every decision signature (governance key);
  - evaluator-report validity (registered evaluator key, bound to baseline/candidate/suite hashes);
  - the evolution-gate decision itself, by an independent re-implementation of the rules
    (protected components, structural authority expansion, version/rollback checks, safety/gaming/regression
    comparison, change-type risk class) -- and FAILS if CAIN's decision differs or is more permissive;
  - that no agent key signed an approval/evaluation/attestation/rollback, and no proposer approved itself;
  - the signed hash-linked evolution log, that every DEPLOY references a deployable decision, and that the
    rollback receipt is governance/operator-signed and logged;
  - the memory ledger chain, attestation signatures, "author never attests itself", and that nothing above
    OBSERVED exists without a registered verifier attestation;
  - the model-route log chain/signatures and that no route chose an unauthorized model.

NOT recomputed (stated): the evaluator's raw measurements (only its signature and the decision derived from
them), and `dependencies_ok` (registry state at decision time; taken from the signed record).

    python3 verify_adaptive_bundle.py CAIN42_L5_ADAPTIVE_BUNDLE.json      # exit 0 = VALID
"""
from __future__ import annotations

import base64
import hashlib
import json
import re
import sys

from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric import ed25519

D_MEMORY = "CAIN42/L5-ADAPTIVE-MEMORY-RECORD/v1"
D_MEMORY_ATTEST = "CAIN42/L5-ADAPTIVE-MEMORY-ATTESTATION/v1"
D_PROPOSAL = "CAIN42/L5-EVOLUTION-PROPOSAL/v1"
D_EVAL_REPORT = "CAIN42/L5-EVALUATION-REPORT/v1"
D_DECISION = "CAIN42/L5-EVOLUTION-DECISION/v1"
D_NODE = "CAIN42/L5-EVOLUTION-NODE/v1"
D_ROLLBACK = "CAIN42/L5-EVOLUTION-ROLLBACK/v1"
D_ROUTE = "CAIN42/L5-MODEL-ROUTE/v1"
D_ROUTER = "CAIN42/L5-MODEL-ROUTER-ADMIN/v1"
D_PROMOTION = "CAIN42/L5-SKILL-PROMOTION/v1"
D_APPROVAL = "CAIN42/L5-EVOLUTION-APPROVAL/v1"

PROTECTED = ("constitution", "authority", "trust_roots", "security_controls", "mcp_enforcement", "governance",
             "evaluator", "test_suite", "rollback", "policy_precedence", "audit")
AUTH_KEYS = ("authority", "permissions", "allowed_tools", "tools", "budget", "max_spend", "scopes",
             "delegation_limit", "network", "environments")
CHANGE_TYPES = ("PROMPT", "POLICY", "MEMORY", "SKILL", "TOOL_CONFIGURATION", "ROUTING", "WORKFLOW",
                "PLANNING_STRATEGY", "AGENT_CONFIGURATION", "MODEL", "MODEL_PARAMETERS")
CHANGE_SURFACE = {
    "PROMPT": ("prompt", "prompts", "system_prompt"),
    "PLANNING_STRATEGY": ("strategy", "plan_verify", "planning", "skills"),
    "WORKFLOW": ("workflow", "steps", "skills"),
    "MEMORY": ("memory", "memory_policy"),
    "SKILL": ("skills",),
    "ROUTING": ("routing", "route_preferences"),
    "MODEL": ("model",),
    "MODEL_PARAMETERS": ("model_parameters", "temperature", "max_tokens"),
    "TOOL_CONFIGURATION": ("tool_config", "tools_config"),
    "POLICY": ("policy",),
    "AGENT_CONFIGURATION": ("agent_config", "name", "description"),
}
SAFETY = ("security_violations", "policy_violations", "boundary_violations")
QUALITY = ("correctness", "failure_rate", "latency_ms", "cost", "tool_calls", "trajectory_quality")
HIGHER = ("correctness", "trajectory_quality")
PERMISSIVENESS = {"QUARANTINE": 0, "REJECT": 1, "SHADOW_TEST": 2, "REQUIRE_APPROVAL": 3, "ACCEPT_WITH_LIMITS": 4,
                  "ACCEPT": 5}
TRUST = ["UNVERIFIED", "OBSERVED", "CORROBORATED", "VERIFIED", "AUTHORITATIVE"]
RESOURCE_RE = re.compile(r"^[A-Za-z0-9_:@=+,~-]+(?:/[A-Za-z0-9_:@=+,.~-]+)*(?:/\*|\*)?$")
NAME_RE = re.compile(r"^[a-z0-9_]+(?:[./][a-z0-9_-]+)*$")
PROPOSAL_ID_RE = re.compile(r"^evp_[0-9a-f]{16}$")
_ABSENT = object()


def canon(o) -> bytes:
    return json.dumps(o, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode()


def H(o) -> str:
    return hashlib.sha256(canon(o)).hexdigest()


def digest(domain: str, body: dict) -> str:
    return H({"domain": domain, **body})


def vsig(pub, sig, domain, body) -> bool:
    if not pub or not sig:
        return False
    try:
        ed25519.Ed25519PublicKey.from_public_bytes(base64.b64decode(pub)).verify(
            base64.b64decode(sig), digest(domain, body).encode())
        return True
    except (InvalidSignature, ValueError, TypeError):
        return False


def canonical_res(r) -> bool:
    if not isinstance(r, str) or not r or not r.isascii():
        return False
    if any(seg in (".", "..") for seg in r.split("/")):
        return False
    return r == "*" or bool(RESOURCE_RE.match(r))


def res_within(c, p) -> bool:
    if not (canonical_res(c) and canonical_res(p)):
        return False
    return c == p or (p.endswith("*") and c.startswith(p[:-1]))


def auth_within(child, parent) -> bool:
    return all(any(c[0] == p[0] and res_within(c[1], p[1]) for p in parent) for c in child)


def norm_name(x) -> str:
    return re.sub(r"[\s\-]+", "_", str(x).strip().lower())


def protected_in(obj, depth=0) -> list:
    if depth > 16:
        return ["<too deep>"]
    hits = []
    if isinstance(obj, dict):
        for k, v in obj.items():
            if norm_name(k) in PROTECTED:
                hits.append(str(k))
            hits += protected_in(v, depth + 1)
    elif isinstance(obj, list):
        for v in obj:
            hits += protected_in(v, depth + 1)
    return hits


def expansion(b: dict, c: dict) -> list:
    out = []
    for k in AUTH_KEYS:
        bv, cv = b.get(k), c.get(k)
        if cv == bv:
            continue
        if cv is None:
            out.append(f"{k} removed")
        elif bv is None:
            out.append(f"{k} added")
        elif isinstance(cv, (int, float)) and isinstance(bv, (int, float)):
            if cv > bv:
                out.append(k)
        elif isinstance(cv, list) and isinstance(bv, list):
            if (cv and isinstance(cv[0], list)) or (bv and isinstance(bv[0], list)):
                if not auth_within(cv, bv):
                    out.append(k)
            elif not set(map(str, cv)) <= set(map(str, bv)):
                out.append(k)
        elif isinstance(cv, dict) and isinstance(bv, dict):
            if any(kk not in cv for kk in bv) or any(
                    kk not in bv or (isinstance(vv, (int, float)) and isinstance(bv.get(kk), (int, float))
                                     and vv > bv[kk]) for kk, vv in cv.items()):
                out.append(k)
        else:
            out.append(k)
    out += [f"shadow {k}" for k in c if k not in AUTH_KEYS and norm_name(k) in AUTH_KEYS]
    return out


def assess(report: dict, tol: float = 0.02) -> str:
    """Returns the first failing class or 'OK'/'NO_IMPROVEMENT'."""
    res = report["results"]
    for r in res.values():
        if any(r["candidate"].get(m, 0) > 0 for m in SAFETY):
            return "SAFETY"
    if "benchmark" in res and "held_out" in res:
        bg = res["benchmark"]["candidate"].get("correctness", 0) - res["benchmark"]["baseline"].get("correctness", 0)
        hg = res["held_out"]["candidate"].get("correctness", 0) - res["held_out"]["baseline"].get("correctness", 0)
        if bg > tol and hg < -tol:
            return "GAMING"
    for r in res.values():
        b, c = r["baseline"], r["candidate"]
        for m in QUALITY:
            if m in b and m in c:
                worse = (b[m] - c[m]) if m in HIGHER else (c[m] - b[m])
                if worse / max(abs(b[m]), 1e-9) > tol:
                    return "REGRESSION"
    if not any(r["candidate"].get("correctness", 0) > r["baseline"].get("correctness", 0) for r in res.values()):
        return "NO_IMPROVEMENT"
    return "OK"


def model_ok(cand: dict, models: dict, gov: str) -> bool:
    m = models.get(cand.get("model"))
    return bool(m and m.get("authorized") and vsig(gov, m.get("registration_signature"), D_ROUTER,
                                                   m.get("entry_body") or {}))


def expected_decision(rec: dict, roots: dict, models: dict, suites_hash: str) -> tuple:
    """Independent re-implementation of the final gate rules (spec from session 44, 71eecdb). First return wins."""
    p, ctx, base, cand = rec["proposal"], rec["context"], rec["baseline"], rec["candidate"]
    sig_ok = p["proposer"] == p["agent_id"] and vsig(roots["agents"].get(p["proposer"]), rec["proposal_signature"],
                                                      D_PROPOSAL, p)
    if not sig_ok or p["proposer"] not in roots["agents"]:
        return "REJECT", "unsigned"
    if p["candidate_hash"] != H(cand):
        return "REJECT", "candidate mismatch"
    ct = p["change_type"]
    if ct not in CHANGE_TYPES:
        return "REJECT", "type"
    comp = p.get("changed_component") or ""
    segs = [norm_name(x) for x in re.split(r"[./\\:]", str(comp))]
    hits = []
    for k, v in cand.items():
        hits += protected_in(v) if k in base else protected_in({k: v})
    if any(sg in PROTECTED for sg in segs) or hits:
        return "QUARANTINE", "protected"
    if expansion(base, cand):
        return "QUARANTINE", "authority expansion"
    if not isinstance(comp, str) or not NAME_RE.match(norm_name(comp)):
        return "REJECT", "non-canonical component"
    changed = [k for k in set(base) | set(cand) if base.get(k, _ABSENT) != cand.get(k, _ABSENT)]
    if any(k not in CHANGE_SURFACE.get(ct, ()) for k in changed):
        return "REJECT", "undeclared change"
    rb = p.get("rollback_plan") or {}
    dep = ctx["deployed_version"]
    auth = rb.get("rollback_authority") or []
    bad = (not PROPOSAL_ID_RE.match(str(p["proposal_id"])) or p["current_version"] != dep
           or p["proposed_version"] in ctx["known_versions"] or not rb
           or rb.get("previous_version") != dep or rb.get("previous_version") not in ctx["ever_deployed"]
           or rb.get("rollback_artifact") != H(base) or not rb.get("rollback_test") or not auth
           or (p["agent_id"] in auth and len(auth) == 1) or not p.get("test_plan") or not ctx["dependencies_ok"])
    if (ct in ("MODEL", "MODEL_PARAMETERS") or cand.get("model") != base.get("model")) and \
            not model_ok(cand, models, roots["governance_pub"]):
        bad = True
    if bad:
        return "REJECT", "structural"
    r = rec["report"]
    body = {k: v for k, v in r.items() if k != "signature"}
    valid = (vsig(roots["evaluators"].get(r.get("evaluator_id", "")), r.get("signature"), D_EVAL_REPORT, body)
             and r.get("baseline_hash") == H(base) and r.get("candidate_hash") == H(cand)
             and r.get("suite_hash") == suites_hash and r.get("executed_by") == "evaluator")
    if not valid:
        return "REJECT", "evaluation unverified"
    a = assess(r)
    if a != "OK":
        return "REJECT", a
    if ct in ("POLICY", "MODEL", "TOOL_CONFIGURATION", "AGENT_CONFIGURATION"):
        return "REQUIRE_APPROVAL", "type"
    if ct in ("MODEL_PARAMETERS", "ROUTING"):
        return "ACCEPT_WITH_LIMITS", "type"
    if ct in ("PROMPT", "PLANNING_STRATEGY", "WORKFLOW", "MEMORY", "SKILL"):
        return "ACCEPT", "type"
    return "REQUIRE_APPROVAL", "unclassified"


def verify(b: dict) -> dict:
    fails, checks = [], 0
    roots, gov = b["roots"], b["roots"]["governance_pub"]
    agent_keys = set(roots["agents"].values())

    def check(ok, msg):
        nonlocal checks
        checks += 1
        if not ok:
            fails.append(msg)

    check(not (agent_keys & ({gov} | set(roots["evaluators"].values()) | set(roots["verifiers"].values())
                             | set(roots["operators"].values()))), "an agent key holds a governance role")
    # ---------------------------------------------------------------- gate decisions
    decisions = {}
    for rec in b["gate_records"]:
        d = rec["decision"]
        body = {k: v for k, v in d.items() if k != "signature"}
        check(vsig(gov, d["signature"], D_DECISION, body), f"{rec['label']}: decision signature invalid")
        want, why = expected_decision(rec, roots, b["models"], b["suites_hash"])
        check(d["decision"] == want, f"{rec['label']}: CAIN said {d['decision']}, clean-room says {want} ({why})")
        check(PERMISSIVENESS[d["decision"]] <= PERMISSIVENESS[want],
              f"{rec['label']}: CAIN decision more permissive than the rules allow")
        check(d.get("self_reported_claims_used") is False, f"{rec['label']}: agent self-reported claims were used")
        decisions[H(d)] = d
    # ---------------------------------------------------------------- evolution log
    prev = ""
    for i, e in enumerate(b["evolution_log"]):
        body = {k: v for k, v in e.items() if k != "signature"}
        check(e["prev"] == prev, f"evolution log {i}: chain break")
        check(vsig(gov, e["signature"], D_NODE, body), f"evolution log {i}: signature invalid")
        prev = H(e)
        if e["kind"] == "DEPLOY":
            d = decisions.get(e.get("decision_hash"))
            check(d is not None and d["decision"] in ("ACCEPT", "ACCEPT_WITH_LIMITS", "REQUIRE_APPROVAL"),
                  f"evolution log {i}: DEPLOY without a deployable governance decision")
            if d and d["decision"] == "REQUIRE_APPROVAL":
                proposer = next((r["proposal"]["proposer"] for r in b["gate_records"]
                                 if r["proposal"]["proposal_id"] == d["proposal_id"]), None)
                good = [a for a in b.get("approvals", [])
                        if a.get("decision_hash") == e.get("decision_hash") and a.get("operator_id") != proposer
                        and a.get("operator_id") in (e.get("approvals") or [])
                        and roots["operators"].get(a.get("operator_id")) not in agent_keys
                        and vsig(roots["operators"].get(a.get("operator_id")), a.get("signature"), D_APPROVAL,
                                 {k: a.get(k) for k in ("operator_id", "decision_hash", "proposal_id")})]
                check(bool(good), f"evolution log {i}: REQUIRE_APPROVAL deployed without a valid operator approval")
    for v, n in b["nodes"].items():
        check(H(n["artifact"]) == n["artifact_hash"], f"node {v}: artifact altered")
    rr = b.get("rollback_receipt")
    if rr:
        body = {k: rr[k] for k in ("from", "to", "trigger", "by", "evidence_hash")}
        pub = gov if rr["by"] == "governance" else roots["operators"].get(rr["by"])
        check(pub not in agent_keys and vsig(pub, rr["signature"], D_ROLLBACK, body), "rollback receipt signature")
        check(H(rr["evidence"]) == rr["evidence_hash"], "rollback evidence altered")
        check(rr["to"] in b["deploy_history"][:-1], "rollback target was never deployed before")
        receipt_hash = H({k: v for k, v in rr.items()})
        check(any(e["kind"] == "ROLLBACK" and e.get("receipt_hash") == receipt_hash for e in b["evolution_log"]),
              "rollback not recorded in the evolution log")
    # ---------------------------------------------------------------- memory
    prev = ""
    for i, r in enumerate(b["memory_log"]):
        body = {k: v for k, v in r.items() if k not in ("attestations", "content_hash", "record_hash")}
        check(r["prev_hash"] == prev, f"memory {i}: chain break")
        check(digest(D_MEMORY, body) == r["record_hash"], f"memory {i}: record hash mismatch")
        prev = r["record_hash"]
        for a in r["attestations"]:
            ab = {k: a[k] for k in ("memory_id", "version", "content_hash", "verifier_id", "level", "evidence_refs")}
            check(vsig(roots["verifiers"].get(a["verifier_id"]), a["signature"], D_MEMORY_ATTEST, ab),
                  f"memory {i}: attestation by {a['verifier_id']} invalid")
            check(a["verifier_id"] != r["author"], f"memory {i}: author attested its own memory")
        if r["trust"] in TRUST and TRUST.index(r["trust"]) > TRUST.index("OBSERVED"):
            live = {a["verifier_id"] for a in r["attestations"] if a["version"] == r["version"]}
            check(bool(live), f"memory {i}: trust {r['trust']} without a verifier attestation")
            if r["trust"] == "AUTHORITATIVE":
                check(len(live) >= 2, f"memory {i}: AUTHORITATIVE with fewer than 2 verifiers")
        if r["author_kind"] == "agent" and not r["attestations"]:
            check(r["trust"] == "UNVERIFIED", f"memory {i}: agent-written memory above UNVERIFIED")
    # ---------------------------------------------------------------- skills
    for pr in b["skill_promotions"]:
        body = {k: pr[k] for k in ("skill_id", "skill_version", "skill_hash", "to_state", "signer_id", "report_hash")}
        role = roots["evaluators"] if pr["to_state"] == "VERIFIED" else roots["operators"]
        check(vsig(role.get(pr["signer_id"]), pr["signature"], D_PROMOTION, body),
              f"skill {pr['skill_id']}: promotion to {pr['to_state']} not signed by the required role")
    # ---------------------------------------------------------------- model routes
    prev = ""
    for i, e in enumerate(b["route_log"]):
        body = {k: v for k, v in e.items() if k != "signature"}
        check(e["prev"] == prev and vsig(gov, e["signature"], D_ROUTE, body), f"route {i}: chain/signature")
        prev = H(e)
        if e["chosen"]:
            m = b["models"].get(e["chosen"], {})
            check(m.get("authorized") and m.get("max_sensitivity", -1) >= e["sensitivity"],
                  f"route {i}: chose unauthorized / under-cleared model {e['chosen']}")
    return {"verdict": "VALID" if not fails else "INVALID", "checks": checks, "failures": fails,
            "decisions_recomputed": len(b["gate_records"]),
            "decision_mix": {k: sum(1 for r in b["gate_records"] if r["decision"]["decision"] == k)
                             for k in PERMISSIVENESS},
            "not_recomputed": b.get("not_recomputed_by_verifier", [])}


def main() -> int:
    if len(sys.argv) != 2:
        print(__doc__)
        return 2
    out = verify(json.load(open(sys.argv[1])))
    print(json.dumps(out, indent=1))
    return 0 if out["verdict"] == "VALID" else 1


if __name__ == "__main__":
    sys.exit(main())
