rerunner 0.1.1__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.
rerunner/__init__.py ADDED
@@ -0,0 +1,27 @@
1
+ """Rerunner: Deterministic flight recorder, replay engine, and CI test runner for AI agents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rerunner.async_transport import AsyncCassetteTransport
6
+ from rerunner.cassette import Cassette, CassetteError
7
+ from rerunner.sanitizer import sanitize_headers, sanitize_payload, sanitize_string
8
+ from rerunner.session import fork, record, replay
9
+ from rerunner.side_effects import side_effect
10
+ from rerunner.transport import CassetteTransport
11
+
12
+ __version__ = "0.1.1"
13
+
14
+ __all__ = [
15
+ "AsyncCassetteTransport",
16
+ "Cassette",
17
+ "CassetteError",
18
+ "CassetteTransport",
19
+ "fork",
20
+ "record",
21
+ "replay",
22
+ "sanitize_headers",
23
+ "sanitize_payload",
24
+ "sanitize_string",
25
+ "side_effect",
26
+ ]
27
+
@@ -0,0 +1,108 @@
1
+ """Asynchronous httpx.AsyncBaseTransport implementation for recording and replaying HTTP requests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import httpx
6
+ from typing import Literal
7
+ from pathlib import Path
8
+ from rerunner.cassette import Cassette, CassetteError
9
+ from rerunner.matcher import (
10
+ compute_request_key,
11
+ normalize_body,
12
+ normalize_headers,
13
+ )
14
+ from rerunner.models import CassetteEntry, RecordedRequest, RecordedResponse
15
+ from rerunner.sanitizer import sanitize_headers, sanitize_string
16
+
17
+
18
+ class AsyncCassetteTransport(httpx.AsyncBaseTransport):
19
+ """Custom asynchronous httpx transport that records or replays network traffic via a cassette."""
20
+
21
+ def __init__(
22
+ self,
23
+ cassette_path: Path | str,
24
+ mode: Literal["record", "replay", "fork"] = "replay",
25
+ fork_step: int | None = None,
26
+ real_transport: httpx.AsyncBaseTransport | None = None,
27
+ ) -> None:
28
+ self.cassette_path = Path(cassette_path)
29
+ self.mode = mode
30
+ self.fork_step = fork_step
31
+ self.real_transport = real_transport or httpx.AsyncHTTPTransport()
32
+ self.current_step = 0
33
+
34
+ if self.mode in ("replay", "fork") and self.cassette_path.exists():
35
+ self.cassette = Cassette.load(self.cassette_path)
36
+ else:
37
+ self.cassette = Cassette(self.cassette_path)
38
+
39
+ async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
40
+ self.current_step += 1
41
+ body_bytes = await request.aread()
42
+ request_key = compute_request_key(request.method, str(request.url), body_bytes)
43
+
44
+ # Check whether to replay
45
+ should_replay = (
46
+ self.mode == "replay"
47
+ or (self.mode == "fork" and self.fork_step is not None and self.current_step <= self.fork_step)
48
+ )
49
+
50
+ if should_replay:
51
+ match = self.cassette.find_http_match(request_key)
52
+ if match is None or match.response is None:
53
+ raise CassetteError(
54
+ f"[Async Step {self.current_step}] No cassette match found in replay mode for "
55
+ f"{request.method} {request.url} (key: {request_key[:12]}...)"
56
+ )
57
+
58
+ return httpx.Response(
59
+ status_code=match.response.status_code,
60
+ headers=match.response.headers,
61
+ content=match.response.body.encode("utf-8"),
62
+ request=request,
63
+ )
64
+
65
+ # LIVE EXECUTION
66
+ real_response = await self.real_transport.handle_async_request(request)
67
+ response_bytes = await real_response.aread()
68
+
69
+ # Sanitize headers and body before writing to disk
70
+ cleaned_headers = sanitize_headers(normalize_headers(dict(request.headers)))
71
+ cleaned_req_body = sanitize_string(normalize_body(body_bytes))
72
+ cleaned_resp_body = sanitize_string(response_bytes.decode("utf-8", errors="replace"))
73
+
74
+ # Record to cassette
75
+ recorded_req = RecordedRequest(
76
+ method=request.method,
77
+ url=str(request.url),
78
+ headers=cleaned_headers,
79
+ body=cleaned_req_body,
80
+ request_key=request_key,
81
+ )
82
+
83
+ recorded_resp = RecordedResponse(
84
+ status_code=real_response.status_code,
85
+ headers=sanitize_headers(dict(real_response.headers)),
86
+ body=cleaned_resp_body,
87
+ http_version=real_response.extensions.get("http_version", b"HTTP/1.1").decode("ascii", errors="ignore"),
88
+ )
89
+
90
+ entry = CassetteEntry(
91
+ step=self.current_step,
92
+ entry_type="http",
93
+ request=recorded_req,
94
+ response=recorded_resp,
95
+ )
96
+ self.cassette.append(entry)
97
+ self.cassette.save()
98
+
99
+ return httpx.Response(
100
+ status_code=real_response.status_code,
101
+ headers=real_response.headers,
102
+ content=response_bytes,
103
+ request=request,
104
+ )
105
+
106
+ async def aclose(self) -> None:
107
+ if hasattr(self.real_transport, "aclose"):
108
+ await self.real_transport.aclose()
rerunner/cassette.py ADDED
@@ -0,0 +1,119 @@
1
+ """Cassette storage engine for streaming plain and gzip-compressed JSONL reading and writing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING
8
+ from rerunner.integrity import compute_entry_hash, verify_cassette_integrity
9
+ from rerunner.models import CassetteEntry, CassetteMetadata
10
+ import gzip
11
+
12
+ if TYPE_CHECKING:
13
+ from collections.abc import Iterator
14
+
15
+
16
+ class CassetteError(Exception):
17
+ """Raised when cassette file operations fail or integrity is violated."""
18
+
19
+
20
+ class Cassette:
21
+ """Represents a recorded agent session cassette stored as append-only JSONL (.jsonl) or gzip-compressed JSONL (.jsonl.gz)."""
22
+
23
+ def __init__(self, path: Path | str, metadata: CassetteMetadata | None = None) -> None:
24
+ self.path = Path(path)
25
+ self.metadata = metadata or CassetteMetadata()
26
+ self.entries: list[CassetteEntry] = []
27
+ self._last_hash = "GENESIS"
28
+
29
+ @classmethod
30
+ def load(cls, path: Path | str, verify_integrity: bool = True) -> Cassette:
31
+ """Load an existing plain or gzip-compressed cassette from disk and optionally verify its cryptographic chain."""
32
+ file_path = Path(path)
33
+ if not file_path.exists():
34
+ raise FileNotFoundError(f"Cassette file not found: {file_path}")
35
+
36
+ if file_path.name.endswith(".gz"):
37
+ with gzip.open(file_path, "rt", encoding="utf-8") as file:
38
+ lines = file.read().splitlines()
39
+ else:
40
+ lines = file_path.read_text(encoding="utf-8").splitlines()
41
+
42
+ if not lines:
43
+ raise CassetteError(f"Cassette file is empty: {file_path}")
44
+
45
+ # Parse header
46
+ try:
47
+ header_dict = json.loads(lines[0])
48
+ metadata = CassetteMetadata(**header_dict)
49
+ except Exception as exc:
50
+ raise CassetteError(f"Invalid cassette metadata header in {file_path}: {exc}") from exc
51
+
52
+ cassette = cls(file_path, metadata=metadata)
53
+
54
+ for line_num, line in enumerate(lines[1:], start=2):
55
+ line_str = line.strip()
56
+ if not line_str:
57
+ continue
58
+ try:
59
+ entry_dict = json.loads(line_str)
60
+ entry = CassetteEntry(**entry_dict)
61
+ cassette.entries.append(entry)
62
+ except Exception as exc:
63
+ raise CassetteError(
64
+ f"Malformed entry on line {line_num} in {file_path}: {exc}"
65
+ ) from exc
66
+
67
+ if cassette.entries:
68
+ cassette._last_hash = cassette.entries[-1].entry_hash
69
+
70
+ if verify_integrity:
71
+ is_valid, error = verify_cassette_integrity(cassette.entries)
72
+ if not is_valid:
73
+ raise CassetteError(f"Integrity check failed for {file_path}: {error}")
74
+
75
+ return cassette
76
+
77
+ def append(self, entry: CassetteEntry) -> None:
78
+ """Append a new entry to the in-memory cassette with tamper-evident chaining."""
79
+ entry.prev_hash = self._last_hash
80
+ entry.entry_hash = compute_entry_hash(entry, self._last_hash)
81
+ self.entries.append(entry)
82
+ self._last_hash = entry.entry_hash
83
+
84
+ def save(self) -> None:
85
+ """Write the complete cassette to disk atomically, using gzip compression for .gz paths."""
86
+ self.path.parent.mkdir(parents=True, exist_ok=True)
87
+ temp_path = self.path.with_suffix(f"{self.path.suffix}.tmp")
88
+
89
+ if self.path.name.endswith(".gz"):
90
+ with gzip.open(temp_path, "wt", encoding="utf-8") as file:
91
+ file.write(self.metadata.model_dump_json() + "\n")
92
+ for entry in self.entries:
93
+ file.write(entry.model_dump_json() + "\n")
94
+ else:
95
+ with temp_path.open("w", encoding="utf-8") as file:
96
+ # Write metadata header
97
+ file.write(self.metadata.model_dump_json() + "\n")
98
+ for entry in self.entries:
99
+ file.write(entry.model_dump_json() + "\n")
100
+
101
+ temp_path.replace(self.path)
102
+
103
+ def find_http_match(self, request_key: str, step: int | None = None) -> CassetteEntry | None:
104
+ """Find a recorded HTTP response matching the request key, optionally at a specific step."""
105
+ for entry in self.entries:
106
+ if (
107
+ entry.entry_type == "http"
108
+ and entry.request
109
+ and entry.request.request_key == request_key
110
+ and (step is None or entry.step == step)
111
+ ):
112
+ return entry
113
+ return None
114
+
115
+ def __len__(self) -> int:
116
+ return len(self.entries)
117
+
118
+ def __iter__(self) -> Iterator[CassetteEntry]:
119
+ return iter(self.entries)
rerunner/cli.py ADDED
@@ -0,0 +1,203 @@
1
+ """Command-line interface for Rerunner built with Typer and Rich."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+ import typer
8
+ from rich.console import Console
9
+ from rich.panel import Panel
10
+ from rich.table import Table
11
+ from rerunner.cassette import Cassette
12
+ from rerunner.integrity import verify_cassette_integrity
13
+
14
+ app = typer.Typer(
15
+ name="rerunner",
16
+ help="Deterministic flight recorder, replay engine, and CI test runner for AI agents.",
17
+ add_completion=False,
18
+ )
19
+ console = Console()
20
+
21
+
22
+ @app.command()
23
+ def inspect(
24
+ cassette_path: Path = typer.Argument(..., help="Path to a .jsonl or .jsonl.gz cassette file"),
25
+ ) -> None:
26
+ """Inspect and summarize the contents of a recorded cassette."""
27
+ try:
28
+ cassette = Cassette.load(cassette_path, verify_integrity=False)
29
+ except Exception as exc:
30
+ console.print(f"[bold red]Failed to load cassette:[/] {exc}")
31
+ raise typer.Exit(code=1) from exc
32
+
33
+ console.print(
34
+ Panel(
35
+ f"[bold cyan]File:[/] {cassette_path}\n"
36
+ f"[bold cyan]Format Version:[/] {cassette.metadata.format_version}\n"
37
+ f"[bold cyan]Hash Algorithm:[/] {cassette.metadata.hash_algorithm}\n"
38
+ f"[bold cyan]Total Steps:[/] {len(cassette.entries)}",
39
+ title="[bold green]Rerunner Cassette Summary[/]",
40
+ border_style="green",
41
+ )
42
+ )
43
+
44
+ if not cassette.entries:
45
+ console.print("[yellow]Cassette contains no recorded steps.[/]")
46
+ return
47
+
48
+ table = Table(title="Recorded Execution Steps", show_header=True, header_style="bold magenta")
49
+ table.add_column("Step", style="dim", width=6)
50
+ table.add_column("Type", width=12)
51
+ table.add_column("Details", justify="left")
52
+ table.add_column("Hash (SHA-256)", style="dim", width=12)
53
+
54
+ for entry in cassette.entries:
55
+ if entry.entry_type == "http" and entry.request and entry.response:
56
+ details = f"[bold]{entry.request.method}[/] {entry.request.url} -> [green]{entry.response.status_code}[/]"
57
+ elif entry.entry_type == "side_effect":
58
+ details = f"[bold cyan]Tool:[/] {entry.side_effect_name}(...)"
59
+ else:
60
+ details = "Unknown payload"
61
+
62
+ hash_short = f"{entry.entry_hash[:10]}..." if entry.entry_hash else "None"
63
+ table.add_row(str(entry.step), entry.entry_type, details, hash_short)
64
+
65
+ console.print(table)
66
+
67
+
68
+ @app.command()
69
+ def verify(
70
+ cassette_path: Path = typer.Argument(..., help="Path to the .jsonl cassette file"),
71
+ ) -> None:
72
+ """Verify the cryptographic SHA-256 tamper-evident integrity of a cassette."""
73
+ try:
74
+ cassette = Cassette.load(cassette_path, verify_integrity=False)
75
+ is_valid, error = verify_cassette_integrity(cassette.entries)
76
+ if not is_valid:
77
+ console.print(f"[bold red]❌ INTEGRITY FAILURE:[/] {error}")
78
+ sys.exit(1)
79
+ console.print(
80
+ f"[bold green]✔ Cassette integrity verified successfully! ({len(cassette.entries)} chained steps)[/]"
81
+ )
82
+ except Exception as exc:
83
+ console.print(f"[bold red]Error verifying cassette:[/] {exc}")
84
+ sys.exit(1)
85
+
86
+
87
+ @app.command()
88
+ def diff(
89
+ cassette_a: Path = typer.Argument(..., help="First cassette file"),
90
+ cassette_b: Path = typer.Argument(..., help="Second cassette file"),
91
+ ) -> None:
92
+ """Compare two cassettes and highlight divergences in prompt execution and outputs."""
93
+ try:
94
+ c1 = Cassette.load(cassette_a, verify_integrity=False)
95
+ c2 = Cassette.load(cassette_b, verify_integrity=False)
96
+ except Exception as exc:
97
+ console.print(f"[bold red]Error loading cassettes for comparison:[/] {exc}")
98
+ sys.exit(1)
99
+
100
+ table = Table(title="Cassette Divergence Diff", show_header=True, header_style="bold cyan")
101
+ table.add_column("Step", width=6)
102
+ table.add_column(f"{cassette_a.name}", style="yellow")
103
+ table.add_column(f"{cassette_b.name}", style="green")
104
+
105
+ max_len = max(len(c1.entries), len(c2.entries))
106
+ for i in range(max_len):
107
+ e1 = c1.entries[i] if i < len(c1.entries) else None
108
+ e2 = c2.entries[i] if i < len(c2.entries) else None
109
+
110
+ desc1 = f"{e1.entry_type}: {e1.entry_hash[:8]}" if e1 else "[red]<Missing>[/]"
111
+ desc2 = f"{e2.entry_type}: {e2.entry_hash[:8]}" if e2 else "[red]<Missing>[/]"
112
+
113
+ table.add_row(str(i + 1), desc1, desc2)
114
+
115
+ console.print(table)
116
+
117
+
118
+ @app.command()
119
+ def stats(
120
+ cassette_path: Path = typer.Argument(..., help="Path to the .jsonl cassette file"),
121
+ ) -> None:
122
+ """Calculate token savings, latency savings, and cost reductions achieved by replaying."""
123
+ try:
124
+ cassette = Cassette.load(cassette_path, verify_integrity=False)
125
+ except Exception as exc:
126
+ console.print(f"[bold red]Error loading cassette:[/] {exc}")
127
+ sys.exit(1)
128
+
129
+ total_chars = 0
130
+ http_steps = 0
131
+ side_effect_steps = 0
132
+
133
+ for entry in cassette.entries:
134
+ if entry.entry_type == "http" and entry.request and entry.response:
135
+ http_steps += 1
136
+ total_chars += len(entry.request.body) + len(entry.response.body)
137
+ elif entry.entry_type == "side_effect":
138
+ side_effect_steps += 1
139
+
140
+ est_tokens = max(1, total_chars // 4)
141
+ # Estimate based on blend of Claude 3.5 Sonnet / GPT-4o pricing (~$10/M tokens)
142
+ est_dollars_saved = (est_tokens / 1_000_000) * 10.0
143
+ # Average live LLM network call takes ~1.8 seconds vs 0.001s replay
144
+ time_saved_seconds = http_steps * 1.8
145
+
146
+ stats_table = Table(
147
+ title="CI Replay Efficiency & Savings", show_header=True, header_style="bold green"
148
+ )
149
+ stats_table.add_column("Metric", style="cyan")
150
+ stats_table.add_column("Value", style="bold yellow")
151
+
152
+ stats_table.add_row("Total Recorded Steps", str(len(cassette.entries)))
153
+ stats_table.add_row("HTTP LLM Calls Avoided", str(http_steps))
154
+ stats_table.add_row("Side-Effects Safely Stubbed", str(side_effect_steps))
155
+ stats_table.add_row("Estimated Tokens Saved", f"{est_tokens:,}")
156
+ stats_table.add_row("Estimated API Cost Saved (per test run)", f"${est_dollars_saved:.4f}")
157
+ stats_table.add_row("CI Pipeline Time Saved (per test run)", f"{time_saved_seconds:.1f}s")
158
+
159
+ console.print(stats_table)
160
+
161
+
162
+ @app.command()
163
+ def play(
164
+ cassette_path: Path = typer.Argument(..., help="Path to the .jsonl cassette file"),
165
+ ) -> None:
166
+ """Visually step through recorded agent steps in the terminal."""
167
+ try:
168
+ cassette = Cassette.load(cassette_path, verify_integrity=False)
169
+ except Exception as exc:
170
+ console.print(f"[bold red]Error loading cassette:[/] {exc}")
171
+ sys.exit(1)
172
+
173
+ console.print(
174
+ Panel(
175
+ f"[bold green]Replaying {len(cassette.entries)} recorded agent steps...[/]",
176
+ title="📼 Flight Playback",
177
+ )
178
+ )
179
+
180
+ for entry in cassette.entries:
181
+ if entry.entry_type == "http" and entry.request and entry.response:
182
+ console.print(
183
+ Panel(
184
+ f"[bold yellow]Method:[/] {entry.request.method} {entry.request.url}\n"
185
+ f"[bold yellow]Response Code:[/] [green]{entry.response.status_code}[/]\n"
186
+ f"[dim]Body Preview:[/] {entry.response.body[:180]}...",
187
+ title=f"Step {entry.step}: LLM Network Interaction",
188
+ border_style="blue",
189
+ )
190
+ )
191
+ elif entry.entry_type == "side_effect":
192
+ console.print(
193
+ Panel(
194
+ f"[bold cyan]Function:[/] {entry.side_effect_name}\n"
195
+ f"[bold cyan]Result:[/] {entry.side_effect_result}",
196
+ title=f"Step {entry.step}: Guarded Side-Effect",
197
+ border_style="magenta",
198
+ )
199
+ )
200
+
201
+
202
+ if __name__ == "__main__":
203
+ app()
@@ -0,0 +1,13 @@
1
+ """Official framework integrations and zero-friction client adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rerunner.integrations.anthropic import wrap_anthropic
6
+ from rerunner.integrations.langchain import RerunnerCallbackHandler
7
+ from rerunner.integrations.openai import wrap_openai
8
+
9
+ __all__ = [
10
+ "RerunnerCallbackHandler",
11
+ "wrap_anthropic",
12
+ "wrap_openai",
13
+ ]
@@ -0,0 +1,35 @@
1
+ """Zero-friction wrapper for the official Anthropic Claude SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any, Literal
6
+ import httpx
7
+ from rerunner.transport import CassetteTransport
8
+
9
+ if TYPE_CHECKING:
10
+ from pathlib import Path
11
+
12
+
13
+ def wrap_anthropic(
14
+ client: Any,
15
+ cassette_path: Path | str,
16
+ mode: Literal["record", "replay", "fork"] = "replay",
17
+ fork_step: int | None = None,
18
+ ) -> Any:
19
+ """Attach a Rerunner CassetteTransport to an existing Anthropic client instance.
20
+
21
+ Example:
22
+ import anthropic
23
+ from rerunner.integrations import wrap_anthropic
24
+
25
+ client = wrap_anthropic(anthropic.Anthropic(), "my_run.jsonl", mode="replay")
26
+ """
27
+ transport = CassetteTransport(cassette_path=cassette_path, mode=mode, fork_step=fork_step)
28
+ # The official Anthropic SDK uses an internal httpx._client.Client or _client
29
+ if hasattr(client, "_client") and hasattr(client._client, "_transport"):
30
+ client._client._transport = transport
31
+ else:
32
+ # Fallback to replacing http_client
33
+ client._client = httpx.Client(transport=transport)
34
+
35
+ return client
@@ -0,0 +1,29 @@
1
+ """LangChain and LangGraph callback integration for step recording and observability."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+ from rerunner.side_effects import set_active_cassette
7
+
8
+
9
+ class RerunnerCallbackHandler:
10
+ """Callback handler compatible with LangChain, LangGraph, and CrewAI pipelines."""
11
+
12
+ def __init__(self, cassette_path: str, mode: str = "replay", fork_step: int | None = None) -> None:
13
+ self.cassette_path = cassette_path
14
+ self.mode = mode
15
+ self.fork_step = fork_step
16
+ self.step_count = 0
17
+ set_active_cassette(cassette_path, mode=mode, fork_step=fork_step)
18
+
19
+ def on_llm_start(self, serialized: dict[str, Any], prompts: list[str], **kwargs: Any) -> None:
20
+ """Invoked when an LLM call begins in a LangChain chain."""
21
+ self.step_count += 1
22
+
23
+ def on_tool_start(self, serialized: dict[str, Any], input_str: str, **kwargs: Any) -> None:
24
+ """Invoked when a tool executes in an agent workflow."""
25
+ self.step_count += 1
26
+
27
+ def on_chain_end(self, outputs: dict[str, Any], **kwargs: Any) -> None:
28
+ """Invoked when a chain completes execution."""
29
+ pass
@@ -0,0 +1,33 @@
1
+ """Zero-friction wrapper for the official OpenAI SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any, Literal
6
+ import httpx
7
+ from rerunner.transport import CassetteTransport
8
+
9
+ if TYPE_CHECKING:
10
+ from pathlib import Path
11
+
12
+
13
+ def wrap_openai(
14
+ client: Any,
15
+ cassette_path: Path | str,
16
+ mode: Literal["record", "replay", "fork"] = "replay",
17
+ fork_step: int | None = None,
18
+ ) -> Any:
19
+ """Attach a Rerunner CassetteTransport to an existing OpenAI client instance.
20
+
21
+ Example:
22
+ import openai
23
+ from rerunner.integrations import wrap_openai
24
+
25
+ client = wrap_openai(openai.OpenAI(), "my_run.jsonl", mode="replay")
26
+ """
27
+ transport = CassetteTransport(cassette_path=cassette_path, mode=mode, fork_step=fork_step)
28
+ if hasattr(client, "_client") and hasattr(client._client, "_transport"):
29
+ client._client._transport = transport
30
+ else:
31
+ client._client = httpx.Client(transport=transport)
32
+
33
+ return client
rerunner/integrity.py ADDED
@@ -0,0 +1,52 @@
1
+ """Cryptographic tamper-evident hash chaining for cassette verification."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from typing import TYPE_CHECKING
8
+
9
+ if TYPE_CHECKING:
10
+ from rerunner.models import CassetteEntry
11
+
12
+
13
+ def canonical_entry_payload(entry: CassetteEntry) -> str:
14
+ """Create a deterministic JSON string representing the entry without its hash."""
15
+ data = entry.model_dump(exclude={"entry_hash"})
16
+ return json.dumps(data, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
17
+
18
+
19
+ def compute_entry_hash(entry: CassetteEntry, prev_hash: str) -> str:
20
+ """Compute SHA-256 hash chaining prev_hash with canonical entry payload."""
21
+ payload = f"{prev_hash}:{canonical_entry_payload(entry)}"
22
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()
23
+
24
+
25
+ def verify_cassette_integrity(entries: list[CassetteEntry]) -> tuple[bool, str | None]:
26
+ """Verify that every entry in the cassette adheres to the cryptographic hash chain.
27
+
28
+ Returns:
29
+ (True, None) if the chain is fully verified and untampered.
30
+ (False, error_message) if any link in the chain is broken or modified.
31
+ """
32
+ if not entries:
33
+ return True, None
34
+
35
+ expected_prev = "GENESIS"
36
+ for entry in entries:
37
+ if entry.prev_hash != expected_prev:
38
+ return (
39
+ False,
40
+ f"Broken chain at step {entry.step}: expected prev_hash '{expected_prev}', got '{entry.prev_hash}'",
41
+ )
42
+
43
+ recomputed_hash = compute_entry_hash(entry, expected_prev)
44
+ if recomputed_hash != entry.entry_hash:
45
+ return (
46
+ False,
47
+ f"Tampered entry detected at step {entry.step}: recorded hash '{entry.entry_hash}' does not match recomputed '{recomputed_hash}'",
48
+ )
49
+
50
+ expected_prev = entry.entry_hash
51
+
52
+ return True, None