- canon/schemas/execution-attribution-receipt_v0.1.schema.json: the bounded-executor profile of the PCC v0.2 receipt, both audit-core events (started, receipt), every object closed. - tools/execution-attribution-receipt: EAR-1..EAR-10 validator, fixtures generated from the real executor (completed, reverted, completed-on-retry) with a drift check. - executor: started carries receipt_version, digests, steps_planned and data.window.not_after (audit-core AUDIT-WP-0018-T03 request); receipt carries receipt_version and initiator.iss (configured approver_issuer, else unknown). - bounded-executor/exercise_audit_sender.py: real executor and client against audit-core's real ingestion and the Git scope overlay: 202, duplicate 200, conflict 409 as a defect, wrong source/tenant/token refused and not stored, a receipt-less start flagged by its own window. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 523928@bnt-lap001 Assistant-Session: 6c737afb-9c14-4152-9f02-477214b9c350
147 lines
7.1 KiB
Python
147 lines
7.1 KiB
Python
#!/usr/bin/env python3
|
|
"""Execution Attribution Receipt validator, bounded-executor profile (NK-WP-0056-T15, NK-WP-0040-T02 scope).
|
|
|
|
Offline and deterministic. Checks the two audit-core events of one run against
|
|
canon/schemas/execution-attribution-receipt_v0.1.schema.json (EAR-1), then the cross-field rules:
|
|
|
|
EAR-2 envelope id is run:<correlation_id>:started|receipt, and data.run_id equals correlation_id
|
|
EAR-3 subject names the run's admission (started: data.admission; receipt: data.target)
|
|
EAR-4 receipt: envelope tenant equals data.tenant
|
|
EAR-5 receipt: decision_refs are admission, admission_digest, approval, once each; admission matches the subject
|
|
EAR-6 receipt: time_interval.start <= end, and occurred_at equals end
|
|
EAR-7 receipt: reverted is true exactly when a revert step was recorded
|
|
EAR-8 receipt: attempts equals budget.used.attempts and does not exceed budget.limit.attempts
|
|
EAR-9 receipt: outcome agrees with the steps (completed: the final attempt, after the last revert, has every
|
|
step done and every check passed, and a revert implies a retry; reverted: a revert ran and every revert
|
|
step is done)
|
|
EAR-10 pair: started and receipt share run_id, tenant, admission and approval, the started admission_digest is the
|
|
receipt's, started precedes the receipt, and the run ended inside the admission window
|
|
|
|
Findings name a rule and a location, never a value.
|
|
|
|
python3 tools/execution-attribution-receipt/validate.py validate EVENT.json...
|
|
python3 tools/execution-attribution-receipt/validate.py pair STARTED.json RECEIPT.json
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import datetime as dt
|
|
import json
|
|
import pathlib
|
|
import sys
|
|
from typing import Any
|
|
|
|
import jsonschema
|
|
|
|
ROOT = pathlib.Path(__file__).resolve().parents[2]
|
|
SCHEMA = ROOT / "canon/schemas/execution-attribution-receipt_v0.1.schema.json"
|
|
STARTED = "execution.run.started"
|
|
RECEIPT = "execution.attribution.receipt"
|
|
|
|
|
|
def parse_time(value: str) -> dt.datetime:
|
|
return dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
|
|
|
|
def _schema() -> dict:
|
|
return json.loads(SCHEMA.read_text(encoding="utf-8"))
|
|
|
|
|
|
def evaluate(event: Any, schema: dict | None = None) -> dict:
|
|
"""One event. Returns {"valid": bool, "kind": started|receipt|None, "findings": [{"rule", "at"}]}."""
|
|
schema = schema or _schema()
|
|
findings: list[dict] = []
|
|
|
|
def fail(rule: str, at: str) -> None:
|
|
findings.append({"rule": rule, "at": at})
|
|
|
|
validator = jsonschema.Draft202012Validator(schema, format_checker=jsonschema.FormatChecker())
|
|
errors = sorted(validator.iter_errors(event), key=lambda e: list(e.absolute_path))
|
|
if errors:
|
|
for error in errors[:20]:
|
|
fail("EAR-1", "/".join(str(p) for p in error.absolute_path) or "(root)")
|
|
return {"valid": False, "kind": None, "findings": findings}
|
|
|
|
kind = "started" if event["type"] == STARTED else "receipt"
|
|
data = event["data"]
|
|
if event["id"] != f"run:{event['correlation_id']}:{kind}" or data["run_id"] != event["correlation_id"]:
|
|
fail("EAR-2", "id")
|
|
admission = data["admission"] if kind == "started" else data["target"].removeprefix("admission:")
|
|
if event["subject"] != f"admission:{admission}":
|
|
fail("EAR-3", "subject")
|
|
if kind == "receipt":
|
|
if event["tenant"] != data["tenant"]:
|
|
fail("EAR-4", "tenant")
|
|
refs = {r["label"]: r["ref"] for r in data["decision_refs"]}
|
|
if sorted(r["label"] for r in data["decision_refs"]) != ["admission", "admission_digest", "approval"] \
|
|
or refs.get("admission") != admission or not refs.get("admission_digest", "").startswith("sha256:"):
|
|
fail("EAR-5", "data/decision_refs")
|
|
start, end = (parse_time(data["time_interval"][k]) for k in ("start", "end"))
|
|
if start > end or parse_time(event["occurred_at"]) != end:
|
|
fail("EAR-6", "data/time_interval")
|
|
reverts = [s for s in data["steps"] if s["kind"] == "revert"]
|
|
if data["reverted"] != bool(reverts):
|
|
fail("EAR-7", "data/reverted")
|
|
budget = data["budget"]
|
|
if data["attempts"] != budget["used"]["attempts"] or data["attempts"] > budget["limit"]["attempts"]:
|
|
fail("EAR-8", "data/budget")
|
|
if data["outcome"] == "completed":
|
|
# A clean revert may be retried (BEA budget.attempts): earlier attempts may hold failures and reverts.
|
|
# The final attempt is the steps after the last revert; it must be complete and clean.
|
|
last = max((i for i, s in enumerate(data["steps"]) if s["kind"] == "revert"), default=-1)
|
|
final = data["steps"][last + 1:]
|
|
if (not final or (reverts and data["attempts"] < 2)
|
|
or any(s["outcome"] != "done" or any(c["result"] != "pass" for c in s["checks"]) for s in final)):
|
|
fail("EAR-9", "data/outcome")
|
|
if data["outcome"] == "reverted" and (not reverts or any(s["outcome"] != "done" for s in reverts)):
|
|
fail("EAR-9", "data/outcome")
|
|
return {"valid": not findings, "kind": kind, "findings": findings}
|
|
|
|
|
|
def evaluate_pair(started: Any, receipt: Any, schema: dict | None = None) -> dict:
|
|
schema = schema or _schema()
|
|
findings = []
|
|
for name, event, want in (("started", started, "started"), ("receipt", receipt, "receipt")):
|
|
report = evaluate(event, schema)
|
|
findings += [{"rule": f["rule"], "at": f"{name}:{f['at']}"} for f in report["findings"]]
|
|
if report["valid"] and report["kind"] != want:
|
|
findings.append({"rule": "EAR-10", "at": f"{name}:type"})
|
|
if findings:
|
|
return {"valid": False, "findings": findings}
|
|
s, r = started["data"], receipt["data"]
|
|
refs = {x["label"]: x["ref"] for x in r["decision_refs"]}
|
|
checks = {
|
|
"run_id": s["run_id"] == r["run_id"],
|
|
"tenant": started["tenant"] == receipt["tenant"],
|
|
"admission": started["subject"] == receipt["subject"],
|
|
"admission_digest": s["admission_digest"] == refs["admission_digest"],
|
|
"approval": s["approval"] == refs["approval"],
|
|
"order": parse_time(started["occurred_at"]) <= parse_time(receipt["occurred_at"]),
|
|
"window": parse_time(r["time_interval"]["end"]) <= parse_time(s["window"]["not_after"]),
|
|
}
|
|
findings = [{"rule": "EAR-10", "at": k} for k, ok in checks.items() if not ok]
|
|
return {"valid": not findings, "findings": findings}
|
|
|
|
|
|
def _load(path: str) -> Any:
|
|
return json.loads(pathlib.Path(path).read_text(encoding="utf-8"))
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
argv = sys.argv[1:] if argv is None else argv
|
|
if len(argv) >= 2 and argv[0] == "validate":
|
|
ok = True
|
|
for path in argv[1:]:
|
|
report = evaluate(_load(path))
|
|
print(json.dumps({"file": path, **report}, sort_keys=True))
|
|
ok &= report["valid"]
|
|
return 0 if ok else 1
|
|
if len(argv) == 3 and argv[0] == "pair":
|
|
report = evaluate_pair(_load(argv[1]), _load(argv[2]))
|
|
print(json.dumps(report, sort_keys=True))
|
|
return 0 if report["valid"] else 1
|
|
print(__doc__.split("\n\n")[-1], file=sys.stderr)
|
|
return 2
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|