From 6d70f4d97d78f73ab4cbf5ea3163cc180f7d396a Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 29 Sep 2026 21:35:03 +0200 Subject: [PATCH] Evidence export of closed runs and rtut seal (RBT-WP-0009-T04) Co-Authored-By: Claude Sonnet 5.5 Assistant: claude-code Assistant-Model: sonnet Assistant-Process: 295952@bnt-lap001 Assistant-Session: e93f64ad-516c-46eb-9666-aad8d300c477 --- docs/run-spec.md | 9 ++++ rtut/__main__.py | 11 +++++ rtut/projectrun.py | 5 +-- rtut/run.py | 17 ++++--- rtut/ui.py | 23 +++++++++- rtut/ui_projectrun.py | 4 +- rtut/ui_v2.py | 4 +- tests/test_cli.py | 21 +++++++++ tests/test_ui_locked.py | 45 +++++++++++++++++++ tests/test_ui_projectrun.py | 17 +++++++ workplans/RBT-WP-0004-local-runbook-ui.md | 6 +-- ...inalize-the-runbook-tutorials-workplans.md | 6 +-- 12 files changed, 149 insertions(+), 19 deletions(-) diff --git a/docs/run-spec.md b/docs/run-spec.md index 18d1b29..355133c 100644 --- a/docs/run-spec.md +++ b/docs/run-spec.md @@ -119,6 +119,15 @@ Closing a run (`finalize`) makes its record final: A broken seal is called out in the UI, and such a run no longer counts as maturity evidence. This is tamper-evidence, not prevention: someone with file access can still edit a file, but not without being noticed. +## Exporting a closed run as evidence + +A closed run's record can be downloaded as JSON from its page (`GET /run///record.json`; project runs: `/project-run//record.json`), +exactly as stored. An open run has no final record (409). The response carries `X-Rtut-Seal` (`sealed`, `broken` or `unsealed`), so a record that +was changed on disk is still downloadable but flagged. **`rtut seal ` checks an exported record anywhere**: it recomputes the SHA-256 over +the record (without `seal` and `updated`) and prints `sealed` (exit 0), `broken` (1), or `unsealed`/`open` (2). The record is non-secret by +construction (secret-looking text is refused everywhere it could be recorded); it does include any output the operator chose to capture, so +read it before sharing. + ## Changing a value after steps used it A v0.1 run fixes its parameters when it starts. In a v0.2 run datapoints can be edited (guided flow questions, data view), but a value diff --git a/rtut/__main__.py b/rtut/__main__.py index f4137e6..8b41774 100644 --- a/rtut/__main__.py +++ b/rtut/__main__.py @@ -174,6 +174,8 @@ def main(argv: list[str] | None = None) -> int: sp = sub.add_parser("ui", help="serve the local UI on 127.0.0.1") sp.add_argument("roots", nargs="*", default=["."]) sp.add_argument("--port", type=int, default=8877) + sp = sub.add_parser("seal", help="check the seal of an exported run or project-run record (JSON file)") + sp.add_argument("record") sp = sub.add_parser("check", help="validate every pack, attribute set, project and template under a repository") sp.add_argument("root", nargs="?", default=".") sp = sub.add_parser("maturity", help="derived maturity of a pack at its current revision") @@ -213,6 +215,15 @@ def main(argv: list[str] | None = None) -> int: except KeyboardInterrupt: pass return 0 + if a.cmd == "seal": + import json + from . import run as R + try: + state = R.record_seal_state(json.loads(Path(a.record).read_text())) + except (OSError, ValueError) as e: + print(f"error: {e}", file=sys.stderr); return 1 + print(state) + return {"sealed": 0, "unsealed": 2, "open": 2, "broken": 1}[state] if a.cmd == "check": return _check(a.root) if a.cmd == "project": diff --git a/rtut/projectrun.py b/rtut/projectrun.py index bb042c7..918ab68 100644 --- a/rtut/projectrun.py +++ b/rtut/projectrun.py @@ -321,10 +321,7 @@ def finalize(reg: Registry, pr: ProjectRun, outcome: str, note: str = "") -> Non def seal_state(pr: ProjectRun) -> str: - disk = _read_json(pr.path) or pr.data - if not disk.get("outcome"): - return "open" - return "sealed" if disk.get("seal") == _seal_of(disk) else "broken" + return R.record_seal_state(_read_json(pr.path) or pr.data) def times(pr: ProjectRun, now: datetime | None = None) -> dict: diff --git a/rtut/run.py b/rtut/run.py index 0973403..79e21df 100644 --- a/rtut/run.py +++ b/rtut/run.py @@ -374,16 +374,21 @@ def _read_json(path) -> dict | None: return None +def record_seal_state(data: dict) -> str: + """'open' | 'sealed' | 'broken' | 'unsealed' for a run or project-run record, judged from its content alone, + so an exported record can be checked by anyone (`rtut seal `).""" + if not data.get("outcome"): + return "open" + if not data.get("seal"): + return "unsealed" + return "sealed" if data["seal"] == _seal_of(data) else "broken" + + def seal_state(run: Run) -> str: """'open' | 'sealed' | 'broken' (changed after closing, outside the app) | 'unsealed' (closed before sealing existed). Judged on what is on disk, not on the in-memory copy.""" - disk = _read_json(run.path) or run.data - if not disk.get("outcome"): - return "open" - if not disk.get("seal"): - return "unsealed" - return "sealed" if disk["seal"] == _seal_of(disk) else "broken" + return record_seal_state(_read_json(run.path) or run.data) def finalize(run: Run, outcome: str, note: str = "", rating: str | None = None, comment: str = "") -> None: diff --git a/rtut/ui.py b/rtut/ui.py index 61b90ed..f8699dc 100644 --- a/rtut/ui.py +++ b/rtut/ui.py @@ -301,7 +301,9 @@ class App: outcome = r.data.get("outcome") if outcome: note = f" - {e(r.data['outcome_note'])}" if r.data.get("outcome_note") else "" - box = f"

Run finished: {e(outcome)}{note}

{self.locked_note(r)}" + box = (f"

Run finished: {e(outcome)}{note}

{self.locked_note(r)}" + f"

Download the run record (JSON) " + "Check it anywhere with rtut seal <file>.

") return f"

Close this run

{box}
" states = [v["state"] for v in r.data["steps"].values()] btns = [] @@ -480,6 +482,21 @@ def make_handler(app: App, port_holder: list): self.send_header("Content-Disposition", f"attachment; filename=\"{safe}\"") self.end_headers(); self.wfile.write(body) + def _send_record(self, path, filename: str): + """A closed record, exactly as stored, as a download. Its seal can be checked with `rtut seal `.""" + import json + data = json.loads(path.read_text()) + if not data.get("outcome"): + return self._send_file(409, b"the record can be downloaded once the run is closed", "text/plain; charset=utf-8", None) + state = R.record_seal_state(data) + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("X-Content-Type-Options", "nosniff") + self.send_header("X-Rtut-Seal", state) + self.send_header("Content-Security-Policy", "default-src 'none'; sandbox") + self.send_header("Content-Disposition", f"attachment; filename=\"{filename}\"") + self.end_headers(); self.wfile.write(path.read_bytes()) + def _host_ok(self) -> bool: return (self.headers.get("Host", "").split(":")[0] in ("127.0.0.1", "localhost")) @@ -506,6 +523,10 @@ def make_handler(app: App, port_holder: list): body = UA.set_page(app, parts[1]) if body: return self._send(body) + if parts[0] == "run" and len(parts) == 4 and parts[3] == "record.json": + return self._send_record(R.load(app.packs[int(parts[1])], parts[2]).path, f"run-{parts[2]}.json") + if parts[0] == "project-run" and len(parts) == 3 and parts[2] == "record.json": + return self._send_record(PRUN.load(app.registry(), parts[1]).path, f"project-run-{parts[1]}.json") if parts[0] == "run" and len(parts) == 5 and parts[3] == "artefact": status, body, ctype, dl = UI2.serve_artefact(app, int(parts[1]), parts[2], parts[4]) want_dl = bool(parse_qs(urlparse(self.path).query).get("download")) diff --git a/rtut/ui_projectrun.py b/rtut/ui_projectrun.py index 1d89f82..9d7d684 100644 --- a/rtut/ui_projectrun.py +++ b/rtut/ui_projectrun.py @@ -162,7 +162,9 @@ def _close(app, reg, pr, v: dict) -> str: note = f"

Comment: {e(d['outcome_note'])}

" if d.get("outcome_note") else "" broken = ("

⚠ This project run's record was changed outside the app after it was sealed.

" if state == "broken" else "

This project run is closed and locked: its record is sealed and cannot be changed. Start a new project run to try again.

") - return f"

Project run finished: {e(d['outcome'])}

{note}{broken}
" + return (f"

Project run finished: {e(d['outcome'])}

{note}{broken}" + f"

Download the project run record (JSON) " + "Check it anywhere with rtut seal <file>.

") btns = [] if all(r["status"] == "succeeded" for r in v["members"]): btns.append("") diff --git a/rtut/ui_v2.py b/rtut/ui_v2.py index 7b555d7..9051b5e 100644 --- a/rtut/ui_v2.py +++ b/rtut/ui_v2.py @@ -605,7 +605,9 @@ def _close(c: Ctx) -> str: if c.finished: note = f"

Comment: {e(run.data['outcome_note'])}

" if run.data.get("outcome_note") else "" return (f"

Run finished: {e(run.data['outcome'])}

{note}{c.app.locked_note(run)}" - "

The outcome is part of this runbook's score and maturity evidence at this revision.

") + "

The outcome is part of this runbook's score and maturity evidence at this revision.

" + f"

Download the run record (JSON) " + "Check it anywhere with rtut seal <file>.

") fl = c.fl steps = [run.data["steps"][s]["state"] for s in fl.steps] gates = [run.data["gates"][g]["state"] for g in fl.gates] diff --git a/tests/test_cli.py b/tests/test_cli.py index 63a9a41..26f04b3 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -118,3 +118,24 @@ def test_the_schemas_ship_inside_the_package(repo): from rtut import pack, v2, attrs for path in (pack.SCHEMA_PATH, v2.SCHEMA_PATH, attrs.SCHEMA_DIR / "project.schema.json", attrs.SCHEMA_DIR / "attribute-set.schema.json"): assert path.exists() and "rtut/schema" in str(path) + + +def test_seal_command_reports_sealed_broken_open_and_unsealed(repo, capsys, tmp_path): + import json + from rtut import pack as P, run as R1 + pack = P.load(repo / "runbooks/v1/pack.yaml") + r = R1.create(pack, "bernd") + f = tmp_path / "rec.json" + f.write_text(r.path.read_text()) + code, out, _ = run(capsys, "seal", f) + assert code == 2 and out.strip() == "open" # nothing to check yet + R1.finalize(r, "abandoned") + f.write_text(r.path.read_text()) + code, out, _ = run(capsys, "seal", f) + assert code == 0 and out.strip() == "sealed" + d = json.loads(f.read_text()); d["operator"] = "forged"; f.write_text(json.dumps(d)) + assert run(capsys, "seal", f)[:2] == (1, "broken\n") + d = json.loads(r.path.read_text()); d.pop("seal"); f.write_text(json.dumps(d)) + assert run(capsys, "seal", f)[:2] == (2, "unsealed\n") + code, _, err = run(capsys, "seal", tmp_path / "missing.json") + assert code == 1 and "error" in err diff --git a/tests/test_ui_locked.py b/tests/test_ui_locked.py index e2b7a25..2ff9afa 100644 --- a/tests/test_ui_locked.py +++ b/tests/test_ui_locked.py @@ -157,3 +157,48 @@ def test_the_record_file_is_read_only_after_closing_and_tampering_is_called_out( data = json.loads(r.path.read_text()); data["operator"] = "forged"; r.path.write_text(json.dumps(data)) body = req(srv, "GET", base)[2] assert "changed outside the app after it was sealed" in body and "no longer counts towards maturity" in body + + +# ---- evidence export ------------------------------------------------------------------------------------------------------ + +def raw(s, path): + c = http.client.HTTPConnection("127.0.0.1", s.server_address[1]) + c.request("GET", path, None, {"Host": "127.0.0.1"}) + r = c.getresponse() + return r.status, {k.lower(): v for k, v in r.getheaders()}, r.read() + + +@pytest.mark.parametrize("pack", ["rtut.showcase-lighthouse", "rtut.showcase-lighthouse-v2"]) +def test_a_closed_run_record_can_be_downloaded_and_its_seal_checked_elsewhere(srv, pack, tmp_path, capsys): + from rtut.__main__ import main + base, _ = start(srv, pack) + st, _, body = raw(srv, f"{base}/record.json") + assert st == 409 and b"once the run is closed" in body # an open run has no final record + assert "Download the run record" not in req(srv, "GET", base)[2] + post(srv, f"{base}/finalize", outcome="abandoned", note="for the record") + st, hdr, body = raw(srv, f"{base}/record.json") + rid = base.rsplit("/", 1)[1] + assert st == 200 and hdr["content-type"] == "application/json" and hdr["x-rtut-seal"] == "sealed" + assert hdr["content-disposition"] == f'attachment; filename="run-{rid}.json"' and hdr["x-content-type-options"] == "nosniff" + assert "sandbox" in hdr["content-security-policy"] + assert body == run_of(srv, base).path.read_bytes() # exactly as stored + record = json.loads(body) + assert record["run_id"] == rid and record["outcome"] == "abandoned" and R1.record_seal_state(record) == "sealed" + assert f"Download the run record (JSON)" in req(srv, "GET", base)[2] + f = tmp_path / "exported.json"; f.write_bytes(body) + assert main(["seal", str(f)]) == 0 and capsys.readouterr().out.strip() == "sealed" # anyone can verify it + record["operator"] = "forged"; f.write_text(json.dumps(record)) + assert main(["seal", str(f)]) == 1 and capsys.readouterr().out.strip() == "broken" + + +def test_a_tampered_record_is_still_downloadable_but_flagged_in_the_header(srv): + base, _ = start(srv, "rtut.showcase-lighthouse-v2") + post(srv, f"{base}/finalize", outcome="abandoned") + r = run_of(srv, base); r.path.chmod(0o644) + data = json.loads(r.path.read_text()); data["operator"] = "forged"; r.path.write_text(json.dumps(data)) + st, hdr, _ = raw(srv, f"{base}/record.json") + assert st == 200 and hdr["x-rtut-seal"] == "broken" # evidence, with the warning attached + + +def test_unknown_run_record_is_not_found(srv): + assert raw(srv, "/run/0/nope/record.json")[0] == 404 diff --git a/tests/test_ui_projectrun.py b/tests/test_ui_projectrun.py index f2aaa1b..e15befb 100644 --- a/tests/test_ui_projectrun.py +++ b/tests/test_ui_projectrun.py @@ -228,3 +228,20 @@ def test_sidebar_marks_projects_on_a_project_run_page_and_csrf_and_unknown_ids(s assert req(srv, "POST", "/project/demo/runall", {"mode": "manual"})[0] == 403 assert req(srv, "GET", "/project-run/nope")[0] == 404 assert post(srv, "/project-run/nope/tick", n="1")[0] == 400 + + +def test_a_closed_project_run_record_can_be_downloaded(srv): + import json + from rtut import run as RR + loc = start_pr(srv) + c = http.client.HTTPConnection("127.0.0.1", srv.server_address[1]) + c.request("GET", f"{loc}/record.json", None, {"Host": "127.0.0.1"}) + assert c.getresponse().status == 409 # open: no final record yet + post(srv, f"{loc}/finalize", outcome="abandoned") + assert f"Download the project run record (JSON)" in req(srv, "GET", loc)[2] + c = http.client.HTTPConnection("127.0.0.1", srv.server_address[1]) + c.request("GET", f"{loc}/record.json", None, {"Host": "127.0.0.1"}) + r = c.getresponse(); body = r.read() + assert r.status == 200 and r.getheader("X-Rtut-Seal") == "sealed" and "attachment" in r.getheader("Content-Disposition") + rec = json.loads(body) + assert rec["project"] == "demo" and rec["outcome"] == "abandoned" and RR.record_seal_state(rec) == "sealed" diff --git a/workplans/RBT-WP-0004-local-runbook-ui.md b/workplans/RBT-WP-0004-local-runbook-ui.md index baa1c98..5b17cff 100644 --- a/workplans/RBT-WP-0004-local-runbook-ui.md +++ b/workplans/RBT-WP-0004-local-runbook-ui.md @@ -61,13 +61,13 @@ without a browser. ```task id: RBT-WP-0004-T04 -status: todo +status: done priority: medium state_hub_task_id: "616255c2-091c-5948-8160-abd9dc69907f" ``` -Show the run record, export it as non-secret JSON, and produce the exercise-status -diff from RBT-WP-0003-T04. +Done 2026-09-29 (RBT-WP-0009-T04): the run view is the evidence view; a closed run's record downloads as JSON and `rtut seal` verifies it. +The exercise-status diff no longer exists (retired, RBT-WP-0009-T01). ## Fix the check, record and lock-in cycle diff --git a/workplans/RBT-WP-0009-finalize-the-runbook-tutorials-workplans.md b/workplans/RBT-WP-0009-finalize-the-runbook-tutorials-workplans.md index 423d62e..789dd7a 100644 --- a/workplans/RBT-WP-0009-finalize-the-runbook-tutorials-workplans.md +++ b/workplans/RBT-WP-0009-finalize-the-runbook-tutorials-workplans.md @@ -66,12 +66,12 @@ RBT-WP-0006 and NK-WP-0011 to backlog). ```task id: RBT-WP-0009-T04 -status: todo +status: done priority: medium ``` -Close RBT-WP-0004-T04: a closed run's record can be downloaded as JSON from its page (the record is non-secret by construction; -the download is refused for a run that is still open). +Done 2026-09-29, closing RBT-WP-0004-T04: a closed run's (and project run's) record can be downloaded as JSON from its page; an open run is +refused (409). Added `rtut seal ` so an exported record can be verified anywhere. ## Console mapping: is v0.2 expressive enough