runspecimen 0.2.0rc9__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,14 @@
1
+ """RunSpecimen: one approved bounded run with tamper-evident receipts.
2
+
3
+ Local execution assurance for research and engineering workflows.
4
+ """
5
+
6
+ __version__ = "0.2.0rc9"
7
+ PRODUCT_NAME = "RunSpecimen"
8
+
9
+ # Canonical published docs (installed wheels do not embed the markdown tree).
10
+ DOCS_URLS = {
11
+ "about": "https://github.com/darashkevich/runspecimen/blob/main/docs/ABOUT.md",
12
+ "user_guide": "https://github.com/darashkevich/runspecimen/blob/main/docs/USER_GUIDE.md",
13
+ "faq": "https://github.com/darashkevich/runspecimen/blob/main/docs/FAQ.md",
14
+ }
@@ -0,0 +1,4 @@
1
+ from runspecimen.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
runspecimen/approve.py ADDED
@@ -0,0 +1,218 @@
1
+ """Interactive approval binding contract + source hashes with expiry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import sys
7
+ import time
8
+ from pathlib import Path
9
+ from typing import TextIO
10
+
11
+ from runspecimen.atomic import atomic_write_json, read_json
12
+ from runspecimen.contract import Contract, check_contract_paths, load_contract
13
+ from runspecimen.errors import ApprovalError, LeaseError
14
+ from runspecimen.events import EventLog, utc_now_iso
15
+ from runspecimen.hashutil import hash_source
16
+ from runspecimen.lease import hold_workspace_lease
17
+ from runspecimen.paths import (
18
+ APPROVAL_FILENAME,
19
+ ensure_dir,
20
+ resolve_workspace,
21
+ run_state_dir,
22
+ )
23
+ from runspecimen.state import load_state, update_state
24
+ from runspecimen.runtime import runtime_provenance
25
+
26
+ CONFIRM_PHRASE = "APPROVE"
27
+ _TERMINAL_PHASES = frozenset({"running", "completed", "failed", "postflighted", "abandoned"})
28
+
29
+
30
+ def approval_path(state_dir: Path) -> Path:
31
+ return state_dir / APPROVAL_FILENAME
32
+
33
+
34
+ def load_approval(state_dir: Path) -> dict | None:
35
+ path = approval_path(state_dir)
36
+ if not path.exists():
37
+ return None
38
+ return read_json(path)
39
+
40
+
41
+ def require_interactive_tty(
42
+ stdin: TextIO | None = None,
43
+ stdout: TextIO | None = None,
44
+ ) -> None:
45
+ stdin = stdin or sys.stdin
46
+ stdout = stdout or sys.stdout
47
+ if not (
48
+ hasattr(stdin, "isatty")
49
+ and stdin.isatty()
50
+ and hasattr(stdout, "isatty")
51
+ and stdout.isatty()
52
+ ):
53
+ raise ApprovalError(
54
+ "approval requires an interactive TTY on stdin and stdout "
55
+ "(refuse unattended / piped approval)"
56
+ )
57
+
58
+
59
+ def approve_contract(
60
+ *,
61
+ contract_path: Path,
62
+ workspace: Path,
63
+ stdin: TextIO | None = None,
64
+ stdout: TextIO | None = None,
65
+ confirm_phrase: str = CONFIRM_PHRASE,
66
+ now: float | None = None,
67
+ skip_tty_check: bool = False,
68
+ ) -> dict:
69
+ """Prompt on a TTY and write a binding approval document under workspace lease."""
70
+ stdin = stdin or sys.stdin
71
+ stdout = stdout or sys.stdout
72
+ if not skip_tty_check:
73
+ require_interactive_tty(stdin, stdout)
74
+
75
+ workspace = resolve_workspace(workspace)
76
+ contract = load_contract(contract_path)
77
+ check_contract_paths(contract, workspace)
78
+
79
+ try:
80
+ with hold_workspace_lease(workspace, holder="approve"):
81
+ return _approve_under_lease(
82
+ contract=contract,
83
+ workspace=workspace,
84
+ stdin=stdin,
85
+ stdout=stdout,
86
+ confirm_phrase=confirm_phrase,
87
+ now=now,
88
+ )
89
+ except LeaseError as exc:
90
+ raise ApprovalError(str(exc)) from exc
91
+
92
+
93
+ def _approve_under_lease(
94
+ *,
95
+ contract: Contract,
96
+ workspace: Path,
97
+ stdin: TextIO,
98
+ stdout: TextIO,
99
+ confirm_phrase: str,
100
+ now: float | None,
101
+ ) -> dict:
102
+ state_dir = run_state_dir(workspace, contract.campaign_id, contract.run_id)
103
+ ensure_dir(state_dir)
104
+ state = load_state(state_dir)
105
+ phase = state.get("phase")
106
+ if phase in _TERMINAL_PHASES:
107
+ raise ApprovalError(
108
+ f"refuse re-approval: run already in phase={phase!r} "
109
+ f"({contract.campaign_id}/{contract.run_id})"
110
+ )
111
+
112
+ source_hash, _manifest = hash_source(
113
+ workspace, list(contract.source.roots), list(contract.source.excludes)
114
+ )
115
+ runtime = runtime_provenance(contract, workspace)
116
+
117
+ stdout.write(
118
+ f"Approve bounded run?\n"
119
+ f" campaign: {contract.campaign_id}\n"
120
+ f" run_id: {contract.run_id}\n"
121
+ f" argv: {list(contract.argv)!r}\n"
122
+ f" cwd: {contract.cwd}\n"
123
+ f" sources: {list(contract.source.roots)!r}\n"
124
+ f" excludes: {list(contract.source.excludes)!r}\n"
125
+ f" outputs: {list(contract.asserted_output_paths)!r}\n"
126
+ f" timeout: {contract.caps.wall_timeout_sec}s\n"
127
+ f" capture: stdout={contract.caps.stdout_max_bytes}B "
128
+ f"stderr={contract.caps.stderr_max_bytes}B\n"
129
+ f" prior: {contract.predecessor!r}\n"
130
+ f" contract: {contract.contract_hash}\n"
131
+ f" source: {source_hash}\n"
132
+ f" runtime: {runtime['resolved_executable']}\n"
133
+ f" runtime#: {runtime['runtime_id']}\n"
134
+ f" ttl_sec: {contract.approval.ttl_sec}\n"
135
+ f"Type {confirm_phrase!r} to bind this approval: "
136
+ )
137
+ stdout.flush()
138
+ line = stdin.readline()
139
+ if line is None:
140
+ raise ApprovalError("no input for approval confirmation")
141
+ if line.strip() != confirm_phrase:
142
+ raise ApprovalError("approval aborted (confirmation phrase mismatch)")
143
+
144
+ # Re-check phase after interactive pause (still under lease).
145
+ state = load_state(state_dir)
146
+ phase = state.get("phase")
147
+ if phase in _TERMINAL_PHASES:
148
+ raise ApprovalError(
149
+ f"refuse re-approval: run already in phase={phase!r} "
150
+ f"({contract.campaign_id}/{contract.run_id})"
151
+ )
152
+
153
+ ts = time.time() if now is None else now
154
+ expires_at = ts + contract.approval.ttl_sec
155
+ doc = {
156
+ "approved_at": utc_now_iso(),
157
+ "approved_at_unix": ts,
158
+ "expires_at_unix": expires_at,
159
+ "campaign_id": contract.campaign_id,
160
+ "run_id": contract.run_id,
161
+ "contract_path": str(contract.path),
162
+ "contract_hash": contract.contract_hash,
163
+ "source_hash": source_hash,
164
+ "runtime": runtime,
165
+ "ttl_sec": contract.approval.ttl_sec,
166
+ "argv": list(contract.argv),
167
+ }
168
+
169
+ atomic_write_json(approval_path(state_dir), doc)
170
+ log = EventLog.for_state_dir(state_dir)
171
+ log.append(
172
+ "approval",
173
+ {
174
+ "contract_hash": contract.contract_hash,
175
+ "source_hash": source_hash,
176
+ "runtime_id": runtime["runtime_id"],
177
+ "expires_at_unix": expires_at,
178
+ },
179
+ )
180
+ update_state(
181
+ state_dir,
182
+ phase="approved",
183
+ campaign_id=contract.campaign_id,
184
+ run_id=contract.run_id,
185
+ contract_hash=contract.contract_hash,
186
+ source_hash=source_hash,
187
+ runtime=runtime,
188
+ approval_expires_at_unix=expires_at,
189
+ )
190
+ return doc
191
+
192
+
193
+ def approval_is_valid(
194
+ approval: dict,
195
+ contract: Contract,
196
+ source_hash: str,
197
+ *,
198
+ now: float | None = None,
199
+ ) -> tuple[bool, str]:
200
+ ts = time.time() if now is None else now
201
+ if approval.get("contract_hash") != contract.contract_hash:
202
+ return False, "approval contract_hash mismatch (stale or wrong contract)"
203
+ if approval.get("source_hash") != source_hash:
204
+ return False, "approval source_hash mismatch (provenance changed)"
205
+ if approval.get("campaign_id") != contract.campaign_id or approval.get("run_id") != contract.run_id:
206
+ return False, "approval run identity mismatch"
207
+ expires = approval.get("expires_at_unix")
208
+ if isinstance(expires, bool) or not isinstance(expires, (int, float)):
209
+ return False, "approval missing or invalid expires_at_unix"
210
+ try:
211
+ finite_expiry = math.isfinite(expires)
212
+ except OverflowError:
213
+ finite_expiry = False
214
+ if not finite_expiry:
215
+ return False, "approval invalid expires_at_unix (must be finite)"
216
+ if ts >= expires:
217
+ return False, "approval expired (stale)"
218
+ return True, "ok"
runspecimen/atomic.py ADDED
@@ -0,0 +1,50 @@
1
+ """Atomic file writes for crash-safe state."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import tempfile
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+
12
+ def atomic_write_bytes(path: Path, data: bytes) -> None:
13
+ path.parent.mkdir(parents=True, exist_ok=True)
14
+ fd, tmp_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=str(path.parent))
15
+ tmp_path = Path(tmp_name)
16
+ try:
17
+ with os.fdopen(fd, "wb") as fh:
18
+ fh.write(data)
19
+ fh.flush()
20
+ os.fsync(fh.fileno())
21
+ os.replace(tmp_path, path)
22
+ # Best-effort directory fsync for durability on crash.
23
+ try:
24
+ dir_fd = os.open(str(path.parent), os.O_RDONLY)
25
+ try:
26
+ os.fsync(dir_fd)
27
+ finally:
28
+ os.close(dir_fd)
29
+ except OSError:
30
+ pass
31
+ except Exception:
32
+ try:
33
+ tmp_path.unlink(missing_ok=True)
34
+ except OSError:
35
+ pass
36
+ raise
37
+
38
+
39
+ def atomic_write_text(path: Path, text: str, *, encoding: str = "utf-8") -> None:
40
+ atomic_write_bytes(path, text.encode(encoding))
41
+
42
+
43
+ def atomic_write_json(path: Path, obj: Any, *, sort_keys: bool = True) -> None:
44
+ payload = json.dumps(obj, indent=2, sort_keys=sort_keys, separators=(",", ": ")) + "\n"
45
+ atomic_write_text(path, payload)
46
+
47
+
48
+ def read_json(path: Path) -> Any:
49
+ with path.open("r", encoding="utf-8") as fh:
50
+ return json.load(fh)
@@ -0,0 +1,272 @@
1
+ """Tamper-evident run certificates and strict verification."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from runspecimen.atomic import atomic_write_json, read_json
9
+ from runspecimen.contract import Contract
10
+ from runspecimen.errors import CertificateError
11
+ from runspecimen.events import EventLog, utc_now_iso
12
+ from runspecimen.hashutil import canonical_json_bytes, hash_source, sha256_bytes, sha256_file
13
+ from runspecimen.paths import CERTIFICATE_FILENAME, STATE_FILENAME, ensure_within, run_state_dir
14
+ from runspecimen.state import load_state
15
+ from runspecimen.runtime import runtime_provenance
16
+
17
+
18
+ def certificate_path(state_dir: Path) -> Path:
19
+ return state_dir / CERTIFICATE_FILENAME
20
+
21
+
22
+ def load_certificate(state_dir: Path) -> dict[str, Any] | None:
23
+ path = certificate_path(state_dir)
24
+ if not path.exists():
25
+ return None
26
+ return read_json(path)
27
+
28
+
29
+ def write_certificate(state_dir: Path, cert: dict[str, Any]) -> None:
30
+ atomic_write_json(certificate_path(state_dir), cert)
31
+
32
+
33
+ def build_certificate(
34
+ *,
35
+ contract: Contract,
36
+ state: dict[str, Any],
37
+ source_hash: str,
38
+ output_digests: dict[str, str],
39
+ event_head: str,
40
+ approval: dict[str, Any] | None,
41
+ runtime: dict[str, Any],
42
+ ) -> dict[str, Any]:
43
+ body = {
44
+ "approval_expires_at_unix": (approval or {}).get("expires_at_unix"),
45
+ "campaign_id": contract.campaign_id,
46
+ "contract_hash": contract.contract_hash,
47
+ "event_head": event_head,
48
+ "exit_code": state.get("exit_code"),
49
+ "issued_at": utc_now_iso(),
50
+ "output_digests": dict(sorted(output_digests.items())),
51
+ "run_id": contract.run_id,
52
+ "run_result": state.get("run_result"),
53
+ "source_hash": source_hash,
54
+ "runtime": runtime,
55
+ }
56
+ certificate_id = sha256_bytes(canonical_json_bytes(body))
57
+ return {"certificate_id": certificate_id, **body}
58
+
59
+
60
+ def _recompute_certificate_id(cert: dict[str, Any]) -> str:
61
+ material = {
62
+ "approval_expires_at_unix": cert.get("approval_expires_at_unix"),
63
+ "campaign_id": cert["campaign_id"],
64
+ "contract_hash": cert["contract_hash"],
65
+ "event_head": cert["event_head"],
66
+ "exit_code": cert.get("exit_code"),
67
+ "issued_at": cert["issued_at"],
68
+ "output_digests": cert["output_digests"],
69
+ "run_id": cert["run_id"],
70
+ "run_result": cert.get("run_result"),
71
+ "source_hash": cert["source_hash"],
72
+ "runtime": cert["runtime"],
73
+ }
74
+ return sha256_bytes(canonical_json_bytes(material))
75
+
76
+
77
+ def _verify_issuance_ordering(log: EventLog, cert: dict[str, Any]) -> None:
78
+ records = log.read_all()
79
+ if not records:
80
+ raise CertificateError("event log empty; missing certificate issuance events")
81
+
82
+ assertions_idx = None
83
+ for i, rec in enumerate(records):
84
+ if rec.type == "postflight_assertions_ok" and rec.event_hash == cert["event_head"]:
85
+ assertions_idx = i
86
+ break
87
+ if assertions_idx is None:
88
+ raise CertificateError(
89
+ "certificate event_head does not match a postflight_assertions_ok event"
90
+ )
91
+
92
+ if assertions_idx + 1 >= len(records):
93
+ raise CertificateError("missing certificate_issued event after postflight_assertions_ok")
94
+
95
+ issued = records[assertions_idx + 1]
96
+ if issued.type != "certificate_issued":
97
+ raise CertificateError(
98
+ f"expected certificate_issued immediately after assertions_ok, got {issued.type!r}"
99
+ )
100
+ if issued.body.get("certificate_id") != cert["certificate_id"]:
101
+ raise CertificateError("certificate_issued event certificate_id mismatch")
102
+ if issued.body.get("event_head") != cert["event_head"]:
103
+ raise CertificateError("certificate_issued event event_head mismatch")
104
+
105
+ final_issued = None
106
+ for rec in records:
107
+ if (
108
+ rec.type == "certificate_issued"
109
+ and rec.body.get("certificate_id") == cert["certificate_id"]
110
+ ):
111
+ final_issued = rec
112
+ if final_issued is None:
113
+ raise CertificateError("no matching certificate_issued event")
114
+ if records[-1].event_hash != final_issued.event_hash:
115
+ raise CertificateError("final event is not the matching certificate_issued event")
116
+ if final_issued.event_hash != issued.event_hash:
117
+ raise CertificateError("certificate issuance ordering fork detected")
118
+
119
+
120
+ def _verify_live_outputs(workspace: Path, cert: dict[str, Any]) -> None:
121
+ digests = cert.get("output_digests")
122
+ if not isinstance(digests, dict):
123
+ raise CertificateError("certificate output_digests missing or invalid")
124
+ for rel, expected in digests.items():
125
+ if not isinstance(rel, str) or not isinstance(expected, str):
126
+ raise CertificateError("invalid output_digests entry")
127
+ path = ensure_within(workspace, Path(rel), label=f"output {rel!r}")
128
+ if not path.is_file():
129
+ raise CertificateError(f"recorded output missing on disk: {rel}")
130
+ live = sha256_file(path)
131
+ if live != expected:
132
+ raise CertificateError(
133
+ f"live output digest mismatch for {rel}: expected {expected}, got {live}"
134
+ )
135
+
136
+
137
+ def verify_run_receipt(
138
+ *,
139
+ workspace: Path,
140
+ campaign_id: str,
141
+ run_id: str,
142
+ contract: Contract | None = None,
143
+ require_live_provenance: bool = False,
144
+ ) -> dict[str, Any]:
145
+ """Strict verification of a postflighted run receipt.
146
+
147
+ Always checks: certificate integrity, state identity/phase, event chain,
148
+ assertions_ok→certificate_issued ordering, and live output digests.
149
+
150
+ When require_live_provenance is True, contract must be provided and current
151
+ contract/source hashes are rehashed and compared to the certificate/state.
152
+ """
153
+ workspace = workspace.resolve()
154
+ state_dir = run_state_dir(workspace, campaign_id, run_id)
155
+
156
+ if not (state_dir / STATE_FILENAME).exists():
157
+ raise CertificateError("state.json missing (deleted or never written)")
158
+
159
+ cert = load_certificate(state_dir)
160
+ if cert is None:
161
+ raise CertificateError("certificate not found")
162
+
163
+ required = [
164
+ "certificate_id",
165
+ "campaign_id",
166
+ "run_id",
167
+ "contract_hash",
168
+ "source_hash",
169
+ "event_head",
170
+ "output_digests",
171
+ "issued_at",
172
+ "runtime",
173
+ ]
174
+ for key in required:
175
+ if key not in cert:
176
+ raise CertificateError(f"certificate missing field: {key}")
177
+
178
+ if _recompute_certificate_id(cert) != cert["certificate_id"]:
179
+ raise CertificateError("certificate_id mismatch (tampered certificate body)")
180
+
181
+ if cert["campaign_id"] != campaign_id or cert["run_id"] != run_id:
182
+ raise CertificateError("certificate campaign_id/run_id does not match requested identity")
183
+
184
+ state = load_state(state_dir)
185
+ if state.get("phase") != "postflighted":
186
+ raise CertificateError(f"state phase must be postflighted, got {state.get('phase')!r}")
187
+
188
+ for field in ("campaign_id", "run_id", "contract_hash", "source_hash", "certificate_id"):
189
+ if state.get(field) != cert[field]:
190
+ raise CertificateError(f"state {field} does not match certificate")
191
+
192
+ if state.get("campaign_id") != campaign_id or state.get("run_id") != run_id:
193
+ raise CertificateError("state campaign_id/run_id does not match requested identity")
194
+
195
+ log = EventLog.for_state_dir(state_dir)
196
+ ok, msg = log.verify_chain()
197
+ if not ok:
198
+ raise CertificateError(f"event log chain invalid: {msg}")
199
+
200
+ _verify_issuance_ordering(log, cert)
201
+ _verify_live_outputs(workspace, cert)
202
+
203
+ if require_live_provenance:
204
+ if contract is None:
205
+ raise CertificateError("live provenance verification requires a contract")
206
+ if contract.campaign_id != campaign_id or contract.run_id != run_id:
207
+ raise CertificateError("contract identity does not match campaign_id/run_id")
208
+ if contract.contract_hash != cert["contract_hash"]:
209
+ raise CertificateError(
210
+ f"current contract hash mismatch: live={contract.contract_hash} "
211
+ f"cert={cert['contract_hash']}"
212
+ )
213
+ if state.get("contract_hash") != contract.contract_hash:
214
+ raise CertificateError("state contract_hash does not match live contract")
215
+ live_source, _ = hash_source(
216
+ workspace, list(contract.source.roots), list(contract.source.excludes)
217
+ )
218
+ if live_source != cert["source_hash"]:
219
+ raise CertificateError(
220
+ f"current source hash mismatch: live={live_source} cert={cert['source_hash']}"
221
+ )
222
+ if state.get("source_hash") != live_source:
223
+ raise CertificateError("state source_hash does not match live source")
224
+ live_runtime = runtime_provenance(contract, workspace)
225
+ if live_runtime.get("runtime_id") != cert["runtime"].get("runtime_id"):
226
+ raise CertificateError("current runtime provenance does not match certificate")
227
+ if state.get("runtime", {}).get("runtime_id") != live_runtime.get("runtime_id"):
228
+ raise CertificateError("state runtime provenance does not match live runtime")
229
+
230
+ return {
231
+ "ok": True,
232
+ "certificate_id": cert["certificate_id"],
233
+ "event_chain": msg,
234
+ "event_head": cert["event_head"],
235
+ "campaign_id": campaign_id,
236
+ "run_id": run_id,
237
+ }
238
+
239
+
240
+ def verify_certificate(
241
+ state_dir: Path,
242
+ *,
243
+ workspace: Path | None = None,
244
+ contract: Contract | None = None,
245
+ require_live_provenance: bool = False,
246
+ campaign_id: str | None = None,
247
+ run_id: str | None = None,
248
+ ) -> dict[str, Any]:
249
+ """Wrapper around verify_run_receipt; infers workspace from state_dir when omitted."""
250
+ state_dir = state_dir.resolve()
251
+ if workspace is None:
252
+ # state_dir = ws/.runspecimen/runs/{camp}/{run}
253
+ if len(state_dir.parents) < 4 or state_dir.parents[2].name != ".runspecimen":
254
+ raise CertificateError("cannot infer workspace from state_dir")
255
+ workspace = state_dir.parents[3]
256
+
257
+ state = load_state(state_dir)
258
+ camp = campaign_id or state.get("campaign_id")
259
+ rid = run_id or state.get("run_id")
260
+ if not camp or not rid:
261
+ cert = load_certificate(state_dir)
262
+ if cert is None:
263
+ raise CertificateError("certificate not found and state lacks identity")
264
+ camp = camp or cert["campaign_id"]
265
+ rid = rid or cert["run_id"]
266
+ return verify_run_receipt(
267
+ workspace=workspace,
268
+ campaign_id=str(camp),
269
+ run_id=str(rid),
270
+ contract=contract,
271
+ require_live_provenance=require_live_provenance,
272
+ )