epi-recorder 1.0.0__py3-none-any.whl → 1.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.
- epi_cli/__main__.py +7 -0
- epi_cli/keys.py +3 -3
- epi_cli/ls.py +160 -0
- epi_cli/main.py +73 -8
- epi_cli/record.py +10 -3
- epi_cli/run.py +318 -0
- epi_cli/verify.py +20 -20
- epi_cli/view.py +68 -13
- epi_core/__init__.py +1 -1
- epi_core/redactor.py +14 -1
- epi_core/schemas.py +34 -3
- epi_recorder/__init__.py +1 -1
- epi_recorder/api.py +217 -20
- epi_recorder/environment.py +21 -0
- epi_recorder/patcher.py +88 -7
- epi_recorder-1.1.1.dist-info/METADATA +569 -0
- epi_recorder-1.1.1.dist-info/RECORD +28 -0
- epi_viewer_static/app.js +77 -1
- epi_recorder-1.0.0.dist-info/METADATA +0 -503
- epi_recorder-1.0.0.dist-info/RECORD +0 -25
- {epi_recorder-1.0.0.dist-info → epi_recorder-1.1.1.dist-info}/WHEEL +0 -0
- {epi_recorder-1.0.0.dist-info → epi_recorder-1.1.1.dist-info}/entry_points.txt +0 -0
- {epi_recorder-1.0.0.dist-info → epi_recorder-1.1.1.dist-info}/licenses/LICENSE +0 -0
- {epi_recorder-1.0.0.dist-info → epi_recorder-1.1.1.dist-info}/top_level.txt +0 -0
epi_cli/verify.py
CHANGED
|
@@ -46,7 +46,7 @@ def verify(
|
|
|
46
46
|
"""
|
|
47
47
|
# Ensure file exists
|
|
48
48
|
if not epi_file.exists():
|
|
49
|
-
console.print(f"[red]
|
|
49
|
+
console.print(f"[red][FAIL] Error:[/red] File not found: {epi_file}")
|
|
50
50
|
raise typer.Exit(1)
|
|
51
51
|
|
|
52
52
|
# Initialize verification state
|
|
@@ -65,11 +65,11 @@ def verify(
|
|
|
65
65
|
try:
|
|
66
66
|
manifest = EPIContainer.read_manifest(epi_file)
|
|
67
67
|
if verbose:
|
|
68
|
-
console.print(" [green]
|
|
69
|
-
console.print(" [green]
|
|
70
|
-
console.print(" [green]
|
|
68
|
+
console.print(" [green][OK][/green] Valid ZIP format")
|
|
69
|
+
console.print(" [green][OK][/green] Valid mimetype")
|
|
70
|
+
console.print(" [green][OK][/green] Valid manifest schema")
|
|
71
71
|
except Exception as e:
|
|
72
|
-
console.print(f"[red]
|
|
72
|
+
console.print(f"[red][FAIL] Structural validation failed:[/red] {e}")
|
|
73
73
|
raise typer.Exit(1)
|
|
74
74
|
|
|
75
75
|
# ========== STEP 2: INTEGRITY CHECKS ==========
|
|
@@ -80,9 +80,9 @@ def verify(
|
|
|
80
80
|
|
|
81
81
|
if verbose:
|
|
82
82
|
if integrity_ok:
|
|
83
|
-
console.print(f" [green]
|
|
83
|
+
console.print(f" [green][OK][/green] All {len(manifest.file_manifest)} files verified")
|
|
84
84
|
else:
|
|
85
|
-
console.print(f" [red]
|
|
85
|
+
console.print(f" [red][FAIL][/red] {len(mismatches)} file(s) failed verification")
|
|
86
86
|
for filename, reason in mismatches.items():
|
|
87
87
|
console.print(f" [red]•[/red] {filename}: {reason}")
|
|
88
88
|
|
|
@@ -101,19 +101,19 @@ def verify(
|
|
|
101
101
|
|
|
102
102
|
if verbose:
|
|
103
103
|
if signature_valid:
|
|
104
|
-
console.print(f" [green]
|
|
104
|
+
console.print(f" [green][OK][/green] {sig_message}")
|
|
105
105
|
else:
|
|
106
|
-
console.print(f" [red]
|
|
106
|
+
console.print(f" [red][FAIL][/red] {sig_message}")
|
|
107
107
|
|
|
108
108
|
except FileNotFoundError:
|
|
109
109
|
signature_valid = False
|
|
110
110
|
if verbose:
|
|
111
|
-
console.print(f" [yellow]
|
|
111
|
+
console.print(f" [yellow][WARN][/yellow] Public key not found: {signer_name}")
|
|
112
112
|
console.print(" [dim]Cannot verify signature without public key[/dim]")
|
|
113
113
|
else:
|
|
114
114
|
signature_valid = None
|
|
115
115
|
if verbose:
|
|
116
|
-
console.print(" [yellow]
|
|
116
|
+
console.print(" [yellow][WARN][/yellow] No signature present (unsigned)")
|
|
117
117
|
|
|
118
118
|
# ========== CREATE REPORT ==========
|
|
119
119
|
report = create_verification_report(
|
|
@@ -143,7 +143,7 @@ def verify(
|
|
|
143
143
|
if verbose:
|
|
144
144
|
console.print_exception()
|
|
145
145
|
else:
|
|
146
|
-
console.print(f"[red]
|
|
146
|
+
console.print(f"[red][FAIL] Verification failed:[/red] {e}")
|
|
147
147
|
raise typer.Exit(1)
|
|
148
148
|
|
|
149
149
|
|
|
@@ -158,15 +158,15 @@ def print_trust_report(report: dict, epi_file: Path, verbose: bool = False):
|
|
|
158
158
|
"""
|
|
159
159
|
# Determine overall status symbol and color
|
|
160
160
|
if report["trust_level"] == "HIGH":
|
|
161
|
-
status_symbol = "
|
|
161
|
+
status_symbol = "[OK]"
|
|
162
162
|
status_color = "green"
|
|
163
163
|
panel_style = "green"
|
|
164
164
|
elif report["trust_level"] == "MEDIUM":
|
|
165
|
-
status_symbol = "
|
|
165
|
+
status_symbol = "[WARN]"
|
|
166
166
|
status_color = "yellow"
|
|
167
167
|
panel_style = "yellow"
|
|
168
168
|
else:
|
|
169
|
-
status_symbol = "
|
|
169
|
+
status_symbol = "[FAIL]"
|
|
170
170
|
status_color = "red"
|
|
171
171
|
panel_style = "red"
|
|
172
172
|
|
|
@@ -179,17 +179,17 @@ def print_trust_report(report: dict, epi_file: Path, verbose: bool = False):
|
|
|
179
179
|
|
|
180
180
|
# Integrity status
|
|
181
181
|
if report["integrity_ok"]:
|
|
182
|
-
content_lines.append(f"[green]
|
|
182
|
+
content_lines.append(f"[green][OK] Integrity:[/green] Verified ({report['files_checked']} files)")
|
|
183
183
|
else:
|
|
184
|
-
content_lines.append(f"[red]
|
|
184
|
+
content_lines.append(f"[red][FAIL] Integrity:[/red] Failed ({report['mismatches_count']} mismatches)")
|
|
185
185
|
|
|
186
186
|
# Signature status
|
|
187
187
|
if report["signature_valid"]:
|
|
188
|
-
content_lines.append(f"[green]
|
|
188
|
+
content_lines.append(f"[green][OK] Signature:[/green] Valid (key: {report['signer']})")
|
|
189
189
|
elif report["signature_valid"] is None:
|
|
190
|
-
content_lines.append("[yellow]
|
|
190
|
+
content_lines.append("[yellow][WARN] Signature:[/yellow] Not signed")
|
|
191
191
|
else:
|
|
192
|
-
content_lines.append(f"[red]
|
|
192
|
+
content_lines.append(f"[red][FAIL] Signature:[/red] Invalid")
|
|
193
193
|
|
|
194
194
|
# Show metadata if verbose
|
|
195
195
|
if verbose:
|
epi_cli/view.py
CHANGED
|
@@ -17,26 +17,81 @@ console = Console()
|
|
|
17
17
|
|
|
18
18
|
app = typer.Typer(name="view", help="View .epi file in browser")
|
|
19
19
|
|
|
20
|
+
DEFAULT_DIR = Path("epi-recordings")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _resolve_epi_file(name_or_path: str) -> Path:
|
|
24
|
+
"""
|
|
25
|
+
Resolve a name or path to an .epi file.
|
|
26
|
+
|
|
27
|
+
Tries in order:
|
|
28
|
+
1. Exact path if it exists
|
|
29
|
+
2. Add .epi extension if missing
|
|
30
|
+
3. Look in ./epi-recordings/ directory
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
name_or_path: User input (name or path)
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
Resolved Path object
|
|
37
|
+
|
|
38
|
+
Raises:
|
|
39
|
+
FileNotFoundError if file cannot be found
|
|
40
|
+
"""
|
|
41
|
+
path = Path(name_or_path)
|
|
42
|
+
|
|
43
|
+
# Try exact path
|
|
44
|
+
if path.exists() and path.is_file():
|
|
45
|
+
return path
|
|
46
|
+
|
|
47
|
+
# Try adding .epi extension
|
|
48
|
+
if not str(path).endswith(".epi"):
|
|
49
|
+
with_ext = path.with_suffix(".epi")
|
|
50
|
+
if with_ext.exists():
|
|
51
|
+
return with_ext
|
|
52
|
+
|
|
53
|
+
# Try in default directory
|
|
54
|
+
in_default = DEFAULT_DIR / path.name
|
|
55
|
+
if in_default.exists():
|
|
56
|
+
return in_default
|
|
57
|
+
|
|
58
|
+
# Try in default directory with .epi extension
|
|
59
|
+
in_default_with_ext = DEFAULT_DIR / f"{path.stem}.epi"
|
|
60
|
+
if in_default_with_ext.exists():
|
|
61
|
+
return in_default_with_ext
|
|
62
|
+
|
|
63
|
+
# Not found
|
|
64
|
+
raise FileNotFoundError(f"Recording not found: {name_or_path}")
|
|
65
|
+
|
|
20
66
|
|
|
21
67
|
@app.callback(invoke_without_command=True)
|
|
22
68
|
def view(
|
|
23
69
|
ctx: typer.Context,
|
|
24
|
-
epi_file:
|
|
70
|
+
epi_file: str = typer.Argument(..., help="Path or name of .epi file to view"),
|
|
25
71
|
):
|
|
26
72
|
"""
|
|
27
73
|
Open .epi file in browser viewer.
|
|
28
74
|
|
|
29
|
-
|
|
30
|
-
|
|
75
|
+
Accepts file path, name, or base name. Automatically resolves:
|
|
76
|
+
- foo -> ./epi-recordings/foo.epi
|
|
77
|
+
- foo.epi -> ./epi-recordings/foo.epi
|
|
78
|
+
- /path/to/file.epi -> /path/to/file.epi
|
|
79
|
+
|
|
80
|
+
Example:
|
|
81
|
+
epi view my_script_20251121_231501
|
|
82
|
+
epi view my_recording.epi
|
|
31
83
|
"""
|
|
32
|
-
#
|
|
33
|
-
|
|
34
|
-
|
|
84
|
+
# Resolve the file path
|
|
85
|
+
try:
|
|
86
|
+
resolved_path = _resolve_epi_file(epi_file)
|
|
87
|
+
except FileNotFoundError as e:
|
|
88
|
+
console.print(f"[red][FAIL] Error:[/red] {e}")
|
|
89
|
+
console.print(f"[dim]Tip: Run 'epi ls' to see available recordings[/dim]")
|
|
35
90
|
raise typer.Exit(1)
|
|
36
91
|
|
|
37
92
|
# Validate it's a ZIP file
|
|
38
|
-
if not zipfile.is_zipfile(
|
|
39
|
-
console.print(f"[red]
|
|
93
|
+
if not zipfile.is_zipfile(resolved_path):
|
|
94
|
+
console.print(f"[red][FAIL] Error:[/red] Not a valid .epi file: {resolved_path}")
|
|
40
95
|
raise typer.Exit(1)
|
|
41
96
|
|
|
42
97
|
try:
|
|
@@ -45,9 +100,9 @@ def view(
|
|
|
45
100
|
viewer_path = temp_dir / "viewer.html"
|
|
46
101
|
|
|
47
102
|
# Extract viewer.html
|
|
48
|
-
with zipfile.ZipFile(
|
|
103
|
+
with zipfile.ZipFile(resolved_path, "r") as zf:
|
|
49
104
|
if "viewer.html" not in zf.namelist():
|
|
50
|
-
console.print("[red]
|
|
105
|
+
console.print("[red][FAIL] Error:[/red] No viewer found in .epi file")
|
|
51
106
|
console.print("[dim]This file may have been created with an older version of EPI[/dim]")
|
|
52
107
|
raise typer.Exit(1)
|
|
53
108
|
|
|
@@ -61,14 +116,14 @@ def view(
|
|
|
61
116
|
success = webbrowser.open(file_url)
|
|
62
117
|
|
|
63
118
|
if success:
|
|
64
|
-
console.print("[green]
|
|
119
|
+
console.print("[green][OK][/green] Viewer opened in browser")
|
|
65
120
|
else:
|
|
66
|
-
console.print("[yellow]
|
|
121
|
+
console.print("[yellow][WARN] Could not open browser automatically[/yellow]")
|
|
67
122
|
console.print(f"[dim]Open manually:[/dim] {file_url}")
|
|
68
123
|
|
|
69
124
|
except KeyboardInterrupt:
|
|
70
125
|
console.print("\n[yellow]Cancelled[/yellow]")
|
|
71
126
|
raise typer.Exit(130)
|
|
72
127
|
except Exception as e:
|
|
73
|
-
console.print(f"[red]
|
|
128
|
+
console.print(f"[red][FAIL] Error:[/red] {e}")
|
|
74
129
|
raise typer.Exit(1)
|
epi_core/__init__.py
CHANGED
epi_core/redactor.py
CHANGED
|
@@ -78,17 +78,19 @@ class Redactor:
|
|
|
78
78
|
from captured workflow data.
|
|
79
79
|
"""
|
|
80
80
|
|
|
81
|
-
def __init__(self, config_path: Path | None = None, enabled: bool = True):
|
|
81
|
+
def __init__(self, config_path: Path | None = None, enabled: bool = True, allowlist: List[str] = None):
|
|
82
82
|
"""
|
|
83
83
|
Initialize redactor with optional custom configuration.
|
|
84
84
|
|
|
85
85
|
Args:
|
|
86
86
|
config_path: Optional path to config.toml with custom patterns
|
|
87
87
|
enabled: Whether redaction is enabled (default: True)
|
|
88
|
+
allowlist: Optional list of strings to NEVER redact
|
|
88
89
|
"""
|
|
89
90
|
self.enabled = enabled
|
|
90
91
|
self.patterns: List[Tuple[re.Pattern, str]] = []
|
|
91
92
|
self.env_vars_to_redact = REDACT_ENV_VARS.copy()
|
|
93
|
+
self.allowlist = set(allowlist) if allowlist else set()
|
|
92
94
|
|
|
93
95
|
# Compile default patterns
|
|
94
96
|
for pattern_str, description in DEFAULT_REDACTION_PATTERNS:
|
|
@@ -132,6 +134,10 @@ class Redactor:
|
|
|
132
134
|
# Load custom env vars
|
|
133
135
|
if 'redaction' in config and 'env_vars' in config['redaction']:
|
|
134
136
|
self.env_vars_to_redact.update(config['redaction']['env_vars'])
|
|
137
|
+
|
|
138
|
+
# Load allowlist
|
|
139
|
+
if 'redaction' in config and 'allowlist' in config['redaction']:
|
|
140
|
+
self.allowlist.update(config['redaction']['allowlist'])
|
|
135
141
|
|
|
136
142
|
except Exception as e:
|
|
137
143
|
print(f"Warning: Could not load config from {config_path}: {e}")
|
|
@@ -176,6 +182,10 @@ class Redactor:
|
|
|
176
182
|
return redacted_list, redaction_count
|
|
177
183
|
|
|
178
184
|
elif isinstance(data, str):
|
|
185
|
+
# Check allowlist first
|
|
186
|
+
if data in self.allowlist:
|
|
187
|
+
return data, 0
|
|
188
|
+
|
|
179
189
|
redacted_str = data
|
|
180
190
|
for pattern, description in self.patterns:
|
|
181
191
|
matches = pattern.findall(redacted_str)
|
|
@@ -239,6 +249,9 @@ enabled = true
|
|
|
239
249
|
|
|
240
250
|
# Additional environment variable names to redact
|
|
241
251
|
# env_vars = ["MY_SECRET_VAR", "CUSTOM_TOKEN"]
|
|
252
|
+
|
|
253
|
+
# Allowlist: Strings that should NEVER be redacted (exact match)
|
|
254
|
+
# allowlist = ["sk-not-actually-a-key", "my-public-token"]
|
|
242
255
|
"""
|
|
243
256
|
|
|
244
257
|
config_path.parent.mkdir(parents=True, exist_ok=True)
|
epi_core/schemas.py
CHANGED
|
@@ -3,7 +3,7 @@ EPI Core Schemas - Pydantic models for manifest and steps.
|
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
5
|
from datetime import datetime
|
|
6
|
-
from typing import Any, Dict, Optional
|
|
6
|
+
from typing import Any, Dict, Optional, List, Union
|
|
7
7
|
from uuid import UUID, uuid4
|
|
8
8
|
|
|
9
9
|
from pydantic import BaseModel, ConfigDict, Field
|
|
@@ -52,6 +52,32 @@ class ManifestModel(BaseModel):
|
|
|
52
52
|
description="Ed25519 signature of the canonical CBOR hash of this manifest (excluding signature field)"
|
|
53
53
|
)
|
|
54
54
|
|
|
55
|
+
# New metadata fields for decision tracking
|
|
56
|
+
goal: Optional[str] = Field(
|
|
57
|
+
default=None,
|
|
58
|
+
description="Goal or objective of this workflow execution"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
notes: Optional[str] = Field(
|
|
62
|
+
default=None,
|
|
63
|
+
description="Additional notes or context about this workflow"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
metrics: Optional[Dict[str, Union[float, str]]] = Field(
|
|
67
|
+
default=None,
|
|
68
|
+
description="Key-value metrics for this workflow (accuracy, latency, etc.)"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
approved_by: Optional[str] = Field(
|
|
72
|
+
default=None,
|
|
73
|
+
description="Person or entity who approved this workflow execution"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
tags: Optional[List[str]] = Field(
|
|
77
|
+
default=None,
|
|
78
|
+
description="Tags for categorizing this workflow"
|
|
79
|
+
)
|
|
80
|
+
|
|
55
81
|
model_config = ConfigDict(
|
|
56
82
|
json_schema_extra={
|
|
57
83
|
"example": {
|
|
@@ -65,7 +91,12 @@ class ManifestModel(BaseModel):
|
|
|
65
91
|
"env.json": "a3c5f...",
|
|
66
92
|
"artifacts/output.txt": "c7f8a..."
|
|
67
93
|
},
|
|
68
|
-
"signature": "ed25519:3a4b5c6d..."
|
|
94
|
+
"signature": "ed25519:3a4b5c6d...",
|
|
95
|
+
"goal": "Improve model accuracy",
|
|
96
|
+
"notes": "Switched to GPT-4 for better reasoning",
|
|
97
|
+
"metrics": {"accuracy": 0.92, "latency": 210},
|
|
98
|
+
"approved_by": "alice@company.com",
|
|
99
|
+
"tags": ["prod-candidate", "v1.0"]
|
|
69
100
|
}
|
|
70
101
|
}
|
|
71
102
|
)
|
|
@@ -109,4 +140,4 @@ class StepModel(BaseModel):
|
|
|
109
140
|
}
|
|
110
141
|
}
|
|
111
142
|
}
|
|
112
|
-
)
|
|
143
|
+
)
|
epi_recorder/__init__.py
CHANGED