epi-recorder 1.0.0__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.
epi_core/trust.py ADDED
@@ -0,0 +1,236 @@
1
+ """
2
+ EPI Core Trust - Cryptographic signing and verification using Ed25519.
3
+
4
+ Implements the trust layer for .epi files, ensuring authenticity and integrity
5
+ through digital signatures.
6
+ """
7
+
8
+ import base64
9
+ from pathlib import Path
10
+ from typing import Optional
11
+
12
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
13
+ from cryptography.hazmat.primitives import serialization
14
+ from cryptography.exceptions import InvalidSignature
15
+
16
+ from epi_core.schemas import ManifestModel
17
+ from epi_core.serialize import get_canonical_hash
18
+
19
+
20
+ class SigningError(Exception):
21
+ """Raised when signing operations fail."""
22
+ pass
23
+
24
+
25
+ class VerificationError(Exception):
26
+ """Raised when signature verification fails."""
27
+ pass
28
+
29
+
30
+ def sign_manifest(
31
+ manifest: ManifestModel,
32
+ private_key: Ed25519PrivateKey,
33
+ key_name: str = "default"
34
+ ) -> ManifestModel:
35
+ """
36
+ Sign a manifest using Ed25519 private key.
37
+
38
+ The signing process:
39
+ 1. Compute canonical CBOR hash of manifest (excluding signature field)
40
+ 2. Sign the hash with Ed25519 private key
41
+ 3. Encode signature as base64
42
+ 4. Return new manifest with signature field populated
43
+
44
+ Args:
45
+ manifest: Manifest to sign
46
+ private_key: Ed25519 private key
47
+ key_name: Name of the key used (for verification reference)
48
+
49
+ Returns:
50
+ ManifestModel: New manifest with signature
51
+
52
+ Raises:
53
+ SigningError: If signing fails
54
+ """
55
+ try:
56
+ # Compute canonical hash (excluding signature field)
57
+ manifest_hash = get_canonical_hash(manifest, exclude_fields={"signature"})
58
+ hash_bytes = bytes.fromhex(manifest_hash)
59
+
60
+ # Sign the hash
61
+ signature_bytes = private_key.sign(hash_bytes)
62
+
63
+ # Encode as base64 with key name prefix
64
+ signature_b64 = base64.b64encode(signature_bytes).decode("utf-8")
65
+ signature_str = f"ed25519:{key_name}:{signature_b64}"
66
+
67
+ # Create new manifest with signature
68
+ manifest_dict = manifest.model_dump()
69
+ manifest_dict["signature"] = signature_str
70
+
71
+ return ManifestModel(**manifest_dict)
72
+
73
+ except Exception as e:
74
+ raise SigningError(f"Failed to sign manifest: {e}") from e
75
+
76
+
77
+ def verify_signature(
78
+ manifest: ManifestModel,
79
+ public_key_bytes: bytes
80
+ ) -> tuple[bool, str]:
81
+ """
82
+ Verify manifest signature using Ed25519 public key.
83
+
84
+ Args:
85
+ manifest: Manifest to verify
86
+ public_key_bytes: Raw Ed25519 public key bytes (32 bytes)
87
+
88
+ Returns:
89
+ tuple: (is_valid: bool, message: str)
90
+ """
91
+ # Check if manifest has signature
92
+ if not manifest.signature:
93
+ return (False, "No signature present")
94
+
95
+ try:
96
+ # Parse signature (format: "ed25519:keyname:base64sig")
97
+ parts = manifest.signature.split(":", 2)
98
+ if len(parts) != 3:
99
+ return (False, "Invalid signature format")
100
+
101
+ algorithm, key_name, signature_b64 = parts
102
+
103
+ if algorithm != "ed25519":
104
+ return (False, f"Unsupported signature algorithm: {algorithm}")
105
+
106
+ # Decode signature
107
+ signature_bytes = base64.b64decode(signature_b64)
108
+
109
+ # Compute canonical hash (excluding signature field)
110
+ manifest_hash = get_canonical_hash(manifest, exclude_fields={"signature"})
111
+ hash_bytes = bytes.fromhex(manifest_hash)
112
+
113
+ # Load public key
114
+ public_key = Ed25519PublicKey.from_public_bytes(public_key_bytes)
115
+
116
+ # Verify signature
117
+ public_key.verify(signature_bytes, hash_bytes)
118
+
119
+ return (True, f"Signature valid (key: {key_name})")
120
+
121
+ except InvalidSignature:
122
+ return (False, "Invalid signature - data may have been tampered")
123
+ except Exception as e:
124
+ return (False, f"Verification error: {str(e)}")
125
+
126
+
127
+ def sign_manifest_inplace(
128
+ manifest_path: Path,
129
+ private_key: Ed25519PrivateKey,
130
+ key_name: str = "default"
131
+ ) -> None:
132
+ """
133
+ Sign a manifest file in-place.
134
+
135
+ This reads the manifest JSON, signs it, and writes back the updated version
136
+ with the signature field populated.
137
+
138
+ Args:
139
+ manifest_path: Path to manifest.json file
140
+ private_key: Ed25519 private key
141
+ key_name: Name of the key used
142
+
143
+ Raises:
144
+ FileNotFoundError: If manifest doesn't exist
145
+ SigningError: If signing fails
146
+ """
147
+ if not manifest_path.exists():
148
+ raise FileNotFoundError(f"Manifest not found: {manifest_path}")
149
+
150
+ try:
151
+ # Read manifest
152
+ import json
153
+ manifest_data = json.loads(manifest_path.read_text(encoding="utf-8"))
154
+ manifest = ManifestModel(**manifest_data)
155
+
156
+ # Sign manifest
157
+ signed_manifest = sign_manifest(manifest, private_key, key_name)
158
+
159
+ # Write back
160
+ manifest_path.write_text(
161
+ signed_manifest.model_dump_json(indent=2),
162
+ encoding="utf-8"
163
+ )
164
+
165
+ except Exception as e:
166
+ raise SigningError(f"Failed to sign manifest in-place: {e}") from e
167
+
168
+
169
+ def get_signer_name(signature: Optional[str]) -> Optional[str]:
170
+ """
171
+ Extract signer key name from signature string.
172
+
173
+ Args:
174
+ signature: Signature string (format: "ed25519:keyname:base64sig")
175
+
176
+ Returns:
177
+ str: Key name, or None if signature is invalid/missing
178
+ """
179
+ if not signature:
180
+ return None
181
+
182
+ parts = signature.split(":", 2)
183
+ if len(parts) != 3:
184
+ return None
185
+
186
+ return parts[1]
187
+
188
+
189
+ def create_verification_report(
190
+ integrity_ok: bool,
191
+ signature_valid: Optional[bool],
192
+ signer_name: Optional[str],
193
+ mismatches: dict[str, str],
194
+ manifest: ManifestModel
195
+ ) -> dict:
196
+ """
197
+ Create a structured verification report.
198
+
199
+ Args:
200
+ integrity_ok: Whether file integrity checks passed
201
+ signature_valid: Whether signature is valid (None if no signature)
202
+ signer_name: Name of the signing key
203
+ mismatches: Dict of file mismatches
204
+ manifest: Manifest being verified
205
+
206
+ Returns:
207
+ dict: Verification report
208
+ """
209
+ report = {
210
+ "integrity_ok": integrity_ok,
211
+ "signature_valid": signature_valid,
212
+ "signer": signer_name,
213
+ "has_signature": manifest.signature is not None,
214
+ "spec_version": manifest.spec_version,
215
+ "workflow_id": str(manifest.workflow_id),
216
+ "created_at": manifest.created_at.isoformat(),
217
+ "files_checked": len(manifest.file_manifest),
218
+ "mismatches_count": len(mismatches),
219
+ "mismatches": mismatches,
220
+ }
221
+
222
+ # Compute overall trust level
223
+ if signature_valid and integrity_ok:
224
+ report["trust_level"] = "HIGH"
225
+ report["trust_message"] = "Cryptographically verified and integrity intact"
226
+ elif signature_valid is None and integrity_ok:
227
+ report["trust_level"] = "MEDIUM"
228
+ report["trust_message"] = "Unsigned but integrity intact"
229
+ elif signature_valid is False:
230
+ report["trust_level"] = "NONE"
231
+ report["trust_message"] = "Invalid signature - do not trust"
232
+ else:
233
+ report["trust_level"] = "NONE"
234
+ report["trust_message"] = "Integrity compromised - do not trust"
235
+
236
+ return report
@@ -0,0 +1,21 @@
1
+ """
2
+ EPI Recorder - Runtime interception and workflow capture.
3
+
4
+ Python API for recording AI workflows with cryptographic verification.
5
+ """
6
+
7
+ __version__ = "1.0.0-keystone"
8
+
9
+ # Export Python API
10
+ from epi_recorder.api import (
11
+ EpiRecorderSession,
12
+ record,
13
+ get_current_session
14
+ )
15
+
16
+ __all__ = [
17
+ "EpiRecorderSession",
18
+ "record",
19
+ "get_current_session",
20
+ "__version__"
21
+ ]
epi_recorder/api.py ADDED
@@ -0,0 +1,389 @@
1
+ """
2
+ EPI Recorder Python API - User-friendly library interface.
3
+
4
+ Provides a context manager for recording EPI packages programmatically
5
+ with minimal code changes.
6
+ """
7
+
8
+ import json
9
+ import shutil
10
+ import tempfile
11
+ import threading
12
+ from datetime import datetime
13
+ from pathlib import Path
14
+ from typing import Any, Dict, List, Optional
15
+
16
+ from epi_core.container import EPIContainer
17
+ from epi_core.schemas import ManifestModel
18
+ from epi_core.trust import sign_manifest_inplace
19
+ from epi_recorder.patcher import RecordingContext, set_recording_context, patch_openai
20
+ from epi_recorder.environment import capture_full_environment
21
+
22
+
23
+ # Thread-local storage for active recording sessions
24
+ _thread_local = threading.local()
25
+
26
+
27
+ class EpiRecorderSession:
28
+ """
29
+ Context manager for recording EPI packages.
30
+
31
+ Usage:
32
+ with EpiRecorderSession("my_run.epi", workflow_name="Demo") as epi:
33
+ # Your AI code here - automatically recorded
34
+ response = openai.chat.completions.create(...)
35
+
36
+ # Optional manual logging
37
+ epi.log_step("custom.event", {"data": "value"})
38
+ epi.log_artifact(Path("output.txt"))
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ output_path: Path | str,
44
+ workflow_name: Optional[str] = None,
45
+ tags: Optional[List[str]] = None,
46
+ auto_sign: bool = True,
47
+ redact: bool = True,
48
+ default_key_name: str = "default"
49
+ ):
50
+ """
51
+ Initialize EPI recording session.
52
+
53
+ Args:
54
+ output_path: Path for output .epi file
55
+ workflow_name: Descriptive name for this workflow
56
+ tags: Optional tags for categorization
57
+ auto_sign: Whether to automatically sign on exit (default: True)
58
+ redact: Whether to redact secrets (default: True)
59
+ default_key_name: Name of key to use for signing (default: "default")
60
+ """
61
+ self.output_path = Path(output_path)
62
+ self.workflow_name = workflow_name or "untitled"
63
+ self.tags = tags or []
64
+ self.auto_sign = auto_sign
65
+ self.redact = redact
66
+ self.default_key_name = default_key_name
67
+
68
+ # Runtime state
69
+ self.temp_dir: Optional[Path] = None
70
+ self.recording_context: Optional[RecordingContext] = None
71
+ self.start_time: Optional[datetime] = None
72
+ self._entered = False
73
+
74
+ def __enter__(self) -> "EpiRecorderSession":
75
+ """
76
+ Enter the recording context.
77
+
78
+ Sets up temporary directory, initializes recording context,
79
+ and patches LLM libraries.
80
+ """
81
+ if self._entered:
82
+ raise RuntimeError("EpiRecorderSession cannot be re-entered")
83
+
84
+ self._entered = True
85
+ self.start_time = datetime.utcnow()
86
+
87
+ # Create temporary directory for recording
88
+ self.temp_dir = Path(tempfile.mkdtemp(prefix="epi_recording_"))
89
+
90
+ # Initialize recording context
91
+ self.recording_context = RecordingContext(
92
+ output_dir=self.temp_dir,
93
+ enable_redaction=self.redact
94
+ )
95
+
96
+ # Set as active recording context
97
+ set_recording_context(self.recording_context)
98
+ _thread_local.active_session = self
99
+
100
+ # Patch LLM libraries
101
+ patch_openai() # Patches OpenAI if available
102
+ # TODO: Add more patchers (Anthropic, etc.)
103
+
104
+ # Log session start
105
+ self.log_step("session.start", {
106
+ "workflow_name": self.workflow_name,
107
+ "tags": self.tags,
108
+ "timestamp": self.start_time.isoformat()
109
+ })
110
+
111
+ return self
112
+
113
+ def __exit__(self, exc_type, exc_val, exc_tb):
114
+ """
115
+ Exit the recording context.
116
+
117
+ Finalizes recording, captures environment, packs .epi file,
118
+ and signs it if auto_sign is enabled.
119
+ """
120
+ try:
121
+ # Capture environment snapshot BEFORE session.end
122
+ self._capture_environment()
123
+
124
+ # Log exception if one occurred (before session.end)
125
+ if exc_type is not None:
126
+ self.log_step("session.error", {
127
+ "error_type": exc_type.__name__,
128
+ "error_message": str(exc_val),
129
+ "timestamp": datetime.utcnow().isoformat()
130
+ })
131
+
132
+ # Log session end LAST to ensure it's the final step
133
+ end_time = datetime.utcnow()
134
+ duration = (end_time - self.start_time).total_seconds()
135
+
136
+ self.log_step("session.end", {
137
+ "workflow_name": self.workflow_name,
138
+ "timestamp": end_time.isoformat(),
139
+ "duration_seconds": duration,
140
+ "success": exc_type is None
141
+ })
142
+
143
+ # Create manifest
144
+ # Note: workflow_name and tags are logged in steps, not manifest
145
+ manifest = ManifestModel(
146
+ created_at=self.start_time
147
+ )
148
+
149
+ # Pack into .epi file
150
+ EPIContainer.pack(
151
+ source_dir=self.temp_dir,
152
+ manifest=manifest,
153
+ output_path=self.output_path
154
+ )
155
+
156
+ # Sign if requested
157
+ if self.auto_sign:
158
+ self._sign_epi_file()
159
+
160
+ finally:
161
+ # Clean up temporary directory
162
+ if self.temp_dir and self.temp_dir.exists():
163
+ shutil.rmtree(self.temp_dir, ignore_errors=True)
164
+
165
+ # Clear recording context
166
+ set_recording_context(None)
167
+ if hasattr(_thread_local, 'active_session'):
168
+ delattr(_thread_local, 'active_session')
169
+
170
+ def log_step(self, kind: str, content: Dict[str, Any]) -> None:
171
+ """
172
+ Manually log a custom step.
173
+
174
+ Args:
175
+ kind: Step type (e.g., "custom.calculation", "user.action")
176
+ content: Step data as dictionary
177
+
178
+ Example:
179
+ epi.log_step("data.processed", {
180
+ "rows": 1000,
181
+ "columns": 5,
182
+ "output": "results.csv"
183
+ })
184
+ """
185
+ if not self._entered:
186
+ raise RuntimeError("Cannot log step outside of context manager")
187
+
188
+ self.recording_context.add_step(kind, content)
189
+
190
+ def log_llm_request(self, model: str, payload: Dict[str, Any]) -> None:
191
+ """
192
+ Log an LLM API request.
193
+
194
+ Args:
195
+ model: Model name (e.g., "gpt-4")
196
+ payload: Request payload
197
+
198
+ Note:
199
+ This is typically called automatically by patchers.
200
+ Manual use is for custom integrations.
201
+ """
202
+ self.log_step("llm.request", {
203
+ "provider": "custom",
204
+ "model": model,
205
+ "timestamp": datetime.utcnow().isoformat(),
206
+ **payload
207
+ })
208
+
209
+ def log_llm_response(self, response_payload: Dict[str, Any]) -> None:
210
+ """
211
+ Log an LLM API response.
212
+
213
+ Args:
214
+ response_payload: Response data
215
+
216
+ Note:
217
+ This is typically called automatically by patchers.
218
+ Manual use is for custom integrations.
219
+ """
220
+ self.log_step("llm.response", {
221
+ "timestamp": datetime.utcnow().isoformat(),
222
+ **response_payload
223
+ })
224
+
225
+ def log_artifact(
226
+ self,
227
+ file_path: Path,
228
+ archive_path: Optional[str] = None
229
+ ) -> None:
230
+ """
231
+ Log a file artifact.
232
+
233
+ Copies the file into the recording's artifacts directory.
234
+
235
+ Args:
236
+ file_path: Path to file to capture
237
+ archive_path: Optional path within .epi archive (default: artifacts/<filename>)
238
+
239
+ Example:
240
+ # Capture output file
241
+ with open("results.json", "w") as f:
242
+ json.dump(data, f)
243
+
244
+ epi.log_artifact(Path("results.json"))
245
+ """
246
+ if not self._entered:
247
+ raise RuntimeError("Cannot log artifact outside of context manager")
248
+
249
+ if not file_path.exists():
250
+ raise FileNotFoundError(f"Artifact file not found: {file_path}")
251
+
252
+ # Determine archive path
253
+ if archive_path is None:
254
+ archive_path = f"artifacts/{file_path.name}"
255
+
256
+ # Create artifacts directory
257
+ artifacts_dir = self.temp_dir / "artifacts"
258
+ artifacts_dir.mkdir(exist_ok=True)
259
+
260
+ # Copy file
261
+ dest_path = artifacts_dir / file_path.name
262
+ shutil.copy2(file_path, dest_path)
263
+
264
+ # Log artifact step
265
+ self.log_step("artifact.captured", {
266
+ "source_path": str(file_path),
267
+ "archive_path": archive_path,
268
+ "size_bytes": file_path.stat().st_size,
269
+ "timestamp": datetime.utcnow().isoformat()
270
+ })
271
+
272
+ def _capture_environment(self) -> None:
273
+ """Capture environment snapshot and save to temp directory."""
274
+ try:
275
+ env_data = capture_full_environment()
276
+ env_file = self.temp_dir / "environment.json"
277
+ env_file.write_text(json.dumps(env_data, indent=2), encoding="utf-8")
278
+
279
+ # Log environment capture
280
+ self.log_step("environment.captured", {
281
+ "platform": env_data.get("os", {}).get("platform"),
282
+ "python_version": env_data.get("python", {}).get("version"),
283
+ "timestamp": datetime.utcnow().isoformat()
284
+ })
285
+ except Exception as e:
286
+ # Non-fatal: log but continue
287
+ self.log_step("environment.capture_failed", {
288
+ "error": str(e),
289
+ "timestamp": datetime.utcnow().isoformat()
290
+ })
291
+
292
+ def _sign_epi_file(self) -> None:
293
+ """Sign the .epi file with default key."""
294
+ try:
295
+ from epi_cli.keys import KeyManager
296
+ import zipfile
297
+ import tempfile
298
+ from epi_core.trust import sign_manifest
299
+
300
+ # Load key manager
301
+ km = KeyManager()
302
+
303
+ # Check if default key exists
304
+ if not km.has_key(self.default_key_name):
305
+ # Try to generate default key
306
+ try:
307
+ km.generate_keypair(self.default_key_name)
308
+ except Exception:
309
+ # If generation fails, skip signing
310
+ return
311
+
312
+ # Load private key
313
+ private_key = km.load_private_key(self.default_key_name)
314
+
315
+ # Extract manifest, sign it, and repack
316
+ with tempfile.TemporaryDirectory() as tmpdir:
317
+ tmp_path = Path(tmpdir)
318
+
319
+ # Extract all files
320
+ with zipfile.ZipFile(self.output_path, 'r') as zf:
321
+ zf.extractall(tmp_path)
322
+
323
+ # Load and sign manifest
324
+ manifest_path = tmp_path / "manifest.json"
325
+ manifest_data = json.loads(manifest_path.read_text(encoding="utf-8"))
326
+ manifest = ManifestModel(**manifest_data)
327
+ signed_manifest = sign_manifest(manifest, private_key, self.default_key_name)
328
+
329
+ # Write signed manifest back
330
+ manifest_path.write_text(
331
+ signed_manifest.model_dump_json(indent=2),
332
+ encoding="utf-8"
333
+ )
334
+
335
+ # Repack the ZIP with signed manifest
336
+ self.output_path.unlink() # Remove old file
337
+
338
+ with zipfile.ZipFile(self.output_path, 'w', zipfile.ZIP_DEFLATED) as zf:
339
+ # Write mimetype first (uncompressed)
340
+ from epi_core.container import EPI_MIMETYPE
341
+ zf.writestr("mimetype", EPI_MIMETYPE, compress_type=zipfile.ZIP_STORED)
342
+
343
+ # Write all other files
344
+ for file_path in tmp_path.rglob("*"):
345
+ if file_path.is_file() and file_path.name != "mimetype":
346
+ arc_name = str(file_path.relative_to(tmp_path)).replace("\\", "/")
347
+ zf.write(file_path, arc_name)
348
+
349
+ except Exception as e:
350
+ # Non-fatal: log warning but continue
351
+ print(f"Warning: Failed to sign .epi file: {e}")
352
+
353
+
354
+ # Convenience function for users
355
+ def record(
356
+ output_path: Path | str,
357
+ workflow_name: Optional[str] = None,
358
+ **kwargs
359
+ ) -> EpiRecorderSession:
360
+ """
361
+ Create an EPI recording session (context manager).
362
+
363
+ Args:
364
+ output_path: Path for output .epi file
365
+ workflow_name: Descriptive name for workflow
366
+ **kwargs: Additional arguments (tags, auto_sign, redact, default_key_name)
367
+
368
+ Returns:
369
+ EpiRecorderSession context manager
370
+
371
+ Example:
372
+ from epi_recorder import record
373
+
374
+ with record("my_workflow.epi", workflow_name="Demo"):
375
+ # Your code here
376
+ pass
377
+ """
378
+ return EpiRecorderSession(output_path, workflow_name, **kwargs)
379
+
380
+
381
+ # Make it easy to get current session
382
+ def get_current_session() -> Optional[EpiRecorderSession]:
383
+ """
384
+ Get the currently active recording session (if any).
385
+
386
+ Returns:
387
+ EpiRecorderSession or None
388
+ """
389
+ return getattr(_thread_local, 'active_session', None)
@@ -0,0 +1,58 @@
1
+ """
2
+ EPI Recorder Bootstrap - Initialize recording in child process.
3
+
4
+ This module is loaded via sitecustomize.py in the child process
5
+ to set up LLM patching and recording context.
6
+ """
7
+
8
+ import os
9
+ import sys
10
+ from pathlib import Path
11
+
12
+
13
+ def initialize_recording():
14
+ """
15
+ Initialize EPI recording in child process.
16
+
17
+ This is called automatically via sitecustomize.py when EPI_RECORD=1.
18
+ """
19
+ # Check if recording is enabled
20
+ if os.environ.get("EPI_RECORD") != "1":
21
+ return
22
+
23
+ # Get recording parameters from environment
24
+ steps_dir = os.environ.get("EPI_STEPS_DIR")
25
+ enable_redaction = os.environ.get("EPI_REDACT", "1") == "1"
26
+
27
+ if not steps_dir:
28
+ print("Warning: EPI_STEPS_DIR not set, recording disabled", file=sys.stderr)
29
+ return
30
+
31
+ steps_path = Path(steps_dir)
32
+ if not steps_path.exists():
33
+ print(f"Warning: Steps directory {steps_path} does not exist", file=sys.stderr)
34
+ return
35
+
36
+ try:
37
+ # Import recording modules
38
+ from epi_recorder.patcher import RecordingContext, set_recording_context, patch_all
39
+
40
+ # Create recording context
41
+ context = RecordingContext(steps_path, enable_redaction=enable_redaction)
42
+ set_recording_context(context)
43
+
44
+ # Patch LLM libraries
45
+ patch_results = patch_all()
46
+
47
+ # Optional: Print what was patched (for debugging)
48
+ # for provider, success in patch_results.items():
49
+ # if success:
50
+ # print(f"EPI: Patched {provider}", file=sys.stderr)
51
+
52
+ except Exception as e:
53
+ print(f"Warning: Failed to initialize EPI recording: {e}", file=sys.stderr)
54
+
55
+
56
+ # Auto-initialize if EPI_RECORD is set
57
+ if os.environ.get("EPI_RECORD") == "1":
58
+ initialize_recording()