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_cli/__init__.py +5 -0
- epi_cli/keys.py +272 -0
- epi_cli/main.py +106 -0
- epi_cli/record.py +192 -0
- epi_cli/verify.py +219 -0
- epi_cli/view.py +74 -0
- epi_core/__init__.py +14 -0
- epi_core/container.py +336 -0
- epi_core/redactor.py +266 -0
- epi_core/schemas.py +112 -0
- epi_core/serialize.py +131 -0
- epi_core/trust.py +236 -0
- epi_recorder/__init__.py +21 -0
- epi_recorder/api.py +389 -0
- epi_recorder/bootstrap.py +58 -0
- epi_recorder/environment.py +216 -0
- epi_recorder/patcher.py +356 -0
- epi_recorder-1.0.0.dist-info/METADATA +503 -0
- epi_recorder-1.0.0.dist-info/RECORD +25 -0
- epi_recorder-1.0.0.dist-info/WHEEL +5 -0
- epi_recorder-1.0.0.dist-info/entry_points.txt +2 -0
- epi_recorder-1.0.0.dist-info/licenses/LICENSE +201 -0
- epi_recorder-1.0.0.dist-info/top_level.txt +4 -0
- epi_viewer_static/app.js +267 -0
- epi_viewer_static/index.html +77 -0
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
|
epi_recorder/__init__.py
ADDED
|
@@ -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()
|