Evidence export of closed runs and rtut seal (RBT-WP-0009-T04)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: sonnet Assistant-Process: 295952@bnt-lap001 Assistant-Session: e93f64ad-516c-46eb-9666-aad8d300c477
This commit is contained in:
parent
e021fd979f
commit
6d70f4d97d
12 changed files with 149 additions and 19 deletions
|
|
@ -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/<i>/<run>/record.json`; project runs: `/project-run/<id>/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 <file>` 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
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
17
rtut/run.py
17
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 <file>`)."""
|
||||
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:
|
||||
|
|
|
|||
23
rtut/ui.py
23
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"<p><b>Run finished: {e(outcome)}</b>{note}</p>{self.locked_note(r)}"
|
||||
box = (f"<p><b>Run finished: {e(outcome)}</b>{note}</p>{self.locked_note(r)}"
|
||||
f"<p><a href='/run/{i}/{e(rid)}/record.json'>Download the run record (JSON)</a> "
|
||||
"<span class=note>Check it anywhere with <code>rtut seal <file></code>.</span></p>")
|
||||
return f"<div id=close class=step><h2>Close this run</h2>{box}</div>"
|
||||
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 <file>`."""
|
||||
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"))
|
||||
|
|
|
|||
|
|
@ -162,7 +162,9 @@ def _close(app, reg, pr, v: dict) -> str:
|
|||
note = f"<p>Comment: {e(d['outcome_note'])}</p>" if d.get("outcome_note") else ""
|
||||
broken = ("<p class=bad>⚠ This project run's record was changed outside the app after it was sealed.</p>" if state == "broken" else
|
||||
"<p class=note>This project run is <b>closed and locked</b>: its record is sealed and cannot be changed. Start a new project run to try again.</p>")
|
||||
return f"<div id=close class=step><h2>Project run finished: {e(d['outcome'])}</h2>{note}{broken}</div>"
|
||||
return (f"<div id=close class=step><h2>Project run finished: {e(d['outcome'])}</h2>{note}{broken}"
|
||||
f"<p><a href='/project-run/{e(pr.id)}/record.json'>Download the project run record (JSON)</a> "
|
||||
"<span class=note>Check it anywhere with <code>rtut seal <file></code>.</span></p></div>")
|
||||
btns = []
|
||||
if all(r["status"] == "succeeded" for r in v["members"]):
|
||||
btns.append("<button name=outcome value=succeeded>Finish: succeeded</button>")
|
||||
|
|
|
|||
|
|
@ -605,7 +605,9 @@ def _close(c: Ctx) -> str:
|
|||
if c.finished:
|
||||
note = f"<p>Comment: {e(run.data['outcome_note'])}</p>" if run.data.get("outcome_note") else ""
|
||||
return (f"<div id=close class=step><h2>Run finished: {e(run.data['outcome'])}</h2>{note}{c.app.locked_note(run)}"
|
||||
"<p class=note>The outcome is part of this runbook's score and maturity evidence at this revision.</p></div>")
|
||||
"<p class=note>The outcome is part of this runbook's score and maturity evidence at this revision.</p>"
|
||||
f"<p><a href='{c.base}/record.json'>Download the run record (JSON)</a> "
|
||||
"<span class=note>Check it anywhere with <code>rtut seal <file></code>.</span></p></div>")
|
||||
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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"<a href='{base}/record.json'>Download the run record (JSON)</a>" 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
|
||||
|
|
|
|||
|
|
@ -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"<a href='{loc}/record.json'>Download the project run record (JSON)</a>" 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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <file>` so an exported record can be verified anywhere.
|
||||
|
||||
## Console mapping: is v0.2 expressive enough
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue