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_cli/__init__.py
ADDED
epi_cli/keys.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""
|
|
2
|
+
EPI CLI Keys - Ed25519 key pair management for cryptographic signing.
|
|
3
|
+
|
|
4
|
+
Provides secure key generation, storage, and management following best practices.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import base64
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from cryptography.hazmat.primitives import serialization
|
|
13
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
from rich.table import Table
|
|
16
|
+
|
|
17
|
+
console = Console()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class KeyManager:
|
|
21
|
+
"""
|
|
22
|
+
Manages Ed25519 key pairs for EPI signing.
|
|
23
|
+
|
|
24
|
+
Keys are stored in ~/.epi/keys/ with secure permissions:
|
|
25
|
+
- Private keys: 0600 (owner read/write only)
|
|
26
|
+
- Public keys: 0644 (owner write, all read)
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, keys_dir: Optional[Path] = None):
|
|
30
|
+
"""
|
|
31
|
+
Initialize key manager.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
keys_dir: Optional custom keys directory (default: ~/.epi/keys/)
|
|
35
|
+
"""
|
|
36
|
+
if keys_dir is None:
|
|
37
|
+
self.keys_dir = Path.home() / ".epi" / "keys"
|
|
38
|
+
else:
|
|
39
|
+
self.keys_dir = keys_dir
|
|
40
|
+
|
|
41
|
+
# Ensure keys directory exists with secure permissions
|
|
42
|
+
self.keys_dir.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
|
|
44
|
+
# On Unix-like systems, set directory permissions to 0700
|
|
45
|
+
if os.name != 'nt': # Not Windows
|
|
46
|
+
os.chmod(self.keys_dir, 0o700)
|
|
47
|
+
|
|
48
|
+
def generate_keypair(self, name: str = "default", overwrite: bool = False) -> tuple[Path, Path]:
|
|
49
|
+
"""
|
|
50
|
+
Generate an Ed25519 key pair.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
name: Key pair name
|
|
54
|
+
overwrite: Whether to overwrite existing keys
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
tuple: (private_key_path, public_key_path)
|
|
58
|
+
|
|
59
|
+
Raises:
|
|
60
|
+
FileExistsError: If keys exist and overwrite=False
|
|
61
|
+
"""
|
|
62
|
+
private_key_path = self.keys_dir / f"{name}.key"
|
|
63
|
+
public_key_path = self.keys_dir / f"{name}.pub"
|
|
64
|
+
|
|
65
|
+
# Check for existing keys
|
|
66
|
+
if not overwrite:
|
|
67
|
+
if private_key_path.exists() or public_key_path.exists():
|
|
68
|
+
raise FileExistsError(
|
|
69
|
+
f"Key pair '{name}' already exists. Use --overwrite to replace."
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# Generate Ed25519 key pair
|
|
73
|
+
private_key = Ed25519PrivateKey.generate()
|
|
74
|
+
public_key = private_key.public_key()
|
|
75
|
+
|
|
76
|
+
# Serialize private key (PEM format, no encryption for simplicity in MVP)
|
|
77
|
+
private_pem = private_key.private_bytes(
|
|
78
|
+
encoding=serialization.Encoding.PEM,
|
|
79
|
+
format=serialization.PrivateFormat.PKCS8,
|
|
80
|
+
encryption_algorithm=serialization.NoEncryption()
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# Serialize public key (PEM format)
|
|
84
|
+
public_pem = public_key.public_bytes(
|
|
85
|
+
encoding=serialization.Encoding.PEM,
|
|
86
|
+
format=serialization.PublicFormat.SubjectPublicKeyInfo
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# Write private key with secure permissions
|
|
90
|
+
private_key_path.write_bytes(private_pem)
|
|
91
|
+
if os.name != 'nt': # Unix-like systems
|
|
92
|
+
os.chmod(private_key_path, 0o600) # Owner read/write only
|
|
93
|
+
else: # Windows
|
|
94
|
+
# Set file to be readable only by owner
|
|
95
|
+
import stat
|
|
96
|
+
os.chmod(private_key_path, stat.S_IREAD | stat.S_IWRITE)
|
|
97
|
+
|
|
98
|
+
# Write public key
|
|
99
|
+
public_key_path.write_bytes(public_pem)
|
|
100
|
+
if os.name != 'nt':
|
|
101
|
+
os.chmod(public_key_path, 0o644) # Owner write, all read
|
|
102
|
+
|
|
103
|
+
return private_key_path, public_key_path
|
|
104
|
+
|
|
105
|
+
def load_private_key(self, name: str = "default") -> Ed25519PrivateKey:
|
|
106
|
+
"""
|
|
107
|
+
Load a private key from disk.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
name: Key pair name
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
Ed25519PrivateKey: Loaded private key
|
|
114
|
+
|
|
115
|
+
Raises:
|
|
116
|
+
FileNotFoundError: If key doesn't exist
|
|
117
|
+
"""
|
|
118
|
+
key_path = self.keys_dir / f"{name}.key"
|
|
119
|
+
|
|
120
|
+
if not key_path.exists():
|
|
121
|
+
raise FileNotFoundError(
|
|
122
|
+
f"Private key '{name}' not found. Generate with: epi keys generate --name {name}"
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
key_data = key_path.read_bytes()
|
|
126
|
+
return serialization.load_pem_private_key(key_data, password=None)
|
|
127
|
+
|
|
128
|
+
def load_public_key(self, name: str = "default") -> bytes:
|
|
129
|
+
"""
|
|
130
|
+
Load a public key from disk.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
name: Key pair name
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
bytes: Public key bytes (raw 32 bytes for Ed25519)
|
|
137
|
+
|
|
138
|
+
Raises:
|
|
139
|
+
FileNotFoundError: If key doesn't exist
|
|
140
|
+
"""
|
|
141
|
+
key_path = self.keys_dir / f"{name}.pub"
|
|
142
|
+
|
|
143
|
+
if not key_path.exists():
|
|
144
|
+
raise FileNotFoundError(
|
|
145
|
+
f"Public key '{name}' not found. Generate with: epi keys generate --name {name}"
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
pem_data = key_path.read_bytes()
|
|
149
|
+
public_key = serialization.load_pem_public_key(pem_data)
|
|
150
|
+
|
|
151
|
+
# Return raw 32-byte public key
|
|
152
|
+
return public_key.public_bytes(
|
|
153
|
+
encoding=serialization.Encoding.Raw,
|
|
154
|
+
format=serialization.PublicFormat.Raw
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
def list_keys(self) -> list[dict[str, str]]:
|
|
158
|
+
"""
|
|
159
|
+
List all available key pairs.
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
list: List of dicts with key information
|
|
163
|
+
"""
|
|
164
|
+
keys = []
|
|
165
|
+
|
|
166
|
+
# Find all .pub files
|
|
167
|
+
for pub_file in self.keys_dir.glob("*.pub"):
|
|
168
|
+
key_name = pub_file.stem
|
|
169
|
+
private_exists = (self.keys_dir / f"{key_name}.key").exists()
|
|
170
|
+
|
|
171
|
+
keys.append({
|
|
172
|
+
"name": key_name,
|
|
173
|
+
"has_private": private_exists,
|
|
174
|
+
"has_public": True,
|
|
175
|
+
"public_path": str(pub_file),
|
|
176
|
+
"private_path": str(self.keys_dir / f"{key_name}.key") if private_exists else "N/A"
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
return keys
|
|
180
|
+
|
|
181
|
+
def export_public_key(self, name: str = "default") -> str:
|
|
182
|
+
"""
|
|
183
|
+
Export public key as base64 string for sharing.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
name: Key pair name
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
str: Base64-encoded public key
|
|
190
|
+
"""
|
|
191
|
+
public_key_bytes = self.load_public_key(name)
|
|
192
|
+
return base64.b64encode(public_key_bytes).decode("utf-8")
|
|
193
|
+
|
|
194
|
+
def has_key(self, name: str = "default") -> bool:
|
|
195
|
+
"""
|
|
196
|
+
Check if a key pair exists.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
name: Key pair name
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
bool: True if key pair exists
|
|
203
|
+
"""
|
|
204
|
+
private_path = self.keys_dir / f"{name}.key"
|
|
205
|
+
public_path = self.keys_dir / f"{name}.pub"
|
|
206
|
+
return private_path.exists() and public_path.exists()
|
|
207
|
+
|
|
208
|
+
def has_default_key(self) -> bool:
|
|
209
|
+
"""
|
|
210
|
+
Check if default key pair exists.
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
bool: True if default key exists
|
|
214
|
+
"""
|
|
215
|
+
return (self.keys_dir / "default.key").exists()
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def generate_default_keypair_if_missing(console_output: bool = True) -> bool:
|
|
219
|
+
"""
|
|
220
|
+
Generate default key pair if it doesn't exist (frictionless first run).
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
console_output: Whether to print console messages
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
bool: True if key was generated, False if already exists
|
|
227
|
+
"""
|
|
228
|
+
key_manager = KeyManager()
|
|
229
|
+
|
|
230
|
+
if key_manager.has_default_key():
|
|
231
|
+
return False
|
|
232
|
+
|
|
233
|
+
# Generate default key pair
|
|
234
|
+
private_path, public_path = key_manager.generate_keypair("default")
|
|
235
|
+
|
|
236
|
+
if console_output:
|
|
237
|
+
console.print("\n[bold green]🔐 Welcome to EPI![/bold green]")
|
|
238
|
+
console.print("\n[dim]Generated default Ed25519 key pair for signing:[/dim]")
|
|
239
|
+
console.print(f" [cyan]Private:[/cyan] {private_path}")
|
|
240
|
+
console.print(f" [cyan]Public:[/cyan] {public_path}")
|
|
241
|
+
console.print("\n[dim]Your .epi files will be automatically signed for authenticity.[/dim]\n")
|
|
242
|
+
|
|
243
|
+
return True
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def print_keys_table(keys: list[dict[str, str]]) -> None:
|
|
247
|
+
"""
|
|
248
|
+
Print a formatted table of keys using Rich.
|
|
249
|
+
|
|
250
|
+
Args:
|
|
251
|
+
keys: List of key information dicts
|
|
252
|
+
"""
|
|
253
|
+
if not keys:
|
|
254
|
+
console.print("[yellow]No keys found. Generate with: epi keys generate[/yellow]")
|
|
255
|
+
return
|
|
256
|
+
|
|
257
|
+
table = Table(title="EPI Key Pairs", show_header=True, header_style="bold magenta")
|
|
258
|
+
table.add_column("Name", style="cyan")
|
|
259
|
+
table.add_column("Private Key", style="green")
|
|
260
|
+
table.add_column("Public Key", style="blue")
|
|
261
|
+
|
|
262
|
+
for key in keys:
|
|
263
|
+
private_status = "✅" if key["has_private"] else "❌"
|
|
264
|
+
public_status = "✅" if key["has_public"] else "❌"
|
|
265
|
+
|
|
266
|
+
table.add_row(
|
|
267
|
+
key["name"],
|
|
268
|
+
f"{private_status} {key['private_path']}",
|
|
269
|
+
f"{public_status} {key['public_path']}"
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
console.print(table)
|
epi_cli/main.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""
|
|
2
|
+
EPI CLI Main - Entry point for the EPI command-line interface.
|
|
3
|
+
|
|
4
|
+
Provides the main CLI application with frictionless first-run experience.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
from epi_cli.keys import generate_default_keypair_if_missing
|
|
11
|
+
|
|
12
|
+
# Create Typer app
|
|
13
|
+
app = typer.Typer(
|
|
14
|
+
name="epi",
|
|
15
|
+
help="EPI - Evidence Packaged Infrastructure for AI workflows",
|
|
16
|
+
add_completion=False,
|
|
17
|
+
no_args_is_help=True,
|
|
18
|
+
rich_markup_mode="rich"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
console = Console()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@app.callback()
|
|
25
|
+
def main_callback():
|
|
26
|
+
"""
|
|
27
|
+
Main callback - runs before any command.
|
|
28
|
+
|
|
29
|
+
Implements frictionless first run by auto-generating default key pair.
|
|
30
|
+
"""
|
|
31
|
+
# Auto-generate default keypair if missing (frictionless first run)
|
|
32
|
+
generate_default_keypair_if_missing(console_output=True)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@app.command()
|
|
36
|
+
def version():
|
|
37
|
+
"""Show EPI version information."""
|
|
38
|
+
from epi_core import __version__
|
|
39
|
+
console.print(f"[bold]EPI[/bold] version [cyan]{__version__}[/cyan]")
|
|
40
|
+
console.print("[dim]The PDF for AI workflows[/dim]")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# Import and register subcommands
|
|
44
|
+
# These will be added as they're implemented
|
|
45
|
+
|
|
46
|
+
# Phase 1: verify command
|
|
47
|
+
from epi_cli.verify import verify_app
|
|
48
|
+
app.add_typer(verify_app, name="verify", help="Verify .epi file integrity and authenticity")
|
|
49
|
+
|
|
50
|
+
# Phase 2: record command
|
|
51
|
+
from epi_cli.record import app as record_app
|
|
52
|
+
app.add_typer(record_app, name="record", help="Record a workflow into a .epi file")
|
|
53
|
+
|
|
54
|
+
# Phase 3: view command
|
|
55
|
+
from epi_cli.view import app as view_app
|
|
56
|
+
app.add_typer(view_app, name="view", help="View .epi file in browser")
|
|
57
|
+
|
|
58
|
+
# Phase 1: keys command (for manual key management)
|
|
59
|
+
@app.command()
|
|
60
|
+
def keys(
|
|
61
|
+
action: str = typer.Argument(..., help="Action: generate, list, or export"),
|
|
62
|
+
name: str = typer.Option("default", "--name", "-n", help="Key pair name"),
|
|
63
|
+
overwrite: bool = typer.Option(False, "--overwrite", help="Overwrite existing keys")
|
|
64
|
+
):
|
|
65
|
+
"""Manage Ed25519 key pairs for signing."""
|
|
66
|
+
from epi_cli.keys import KeyManager, print_keys_table
|
|
67
|
+
|
|
68
|
+
key_manager = KeyManager()
|
|
69
|
+
|
|
70
|
+
if action == "generate":
|
|
71
|
+
try:
|
|
72
|
+
private_path, public_path = key_manager.generate_keypair(name, overwrite=overwrite)
|
|
73
|
+
console.print(f"\n[bold green]✅ Generated key pair:[/bold green] {name}")
|
|
74
|
+
console.print(f" [cyan]Private:[/cyan] {private_path}")
|
|
75
|
+
console.print(f" [cyan]Public:[/cyan] {public_path}\n")
|
|
76
|
+
except FileExistsError as e:
|
|
77
|
+
console.print(f"[red]❌ Error:[/red] {e}")
|
|
78
|
+
raise typer.Exit(1)
|
|
79
|
+
|
|
80
|
+
elif action == "list":
|
|
81
|
+
keys_list = key_manager.list_keys()
|
|
82
|
+
print_keys_table(keys_list)
|
|
83
|
+
|
|
84
|
+
elif action == "export":
|
|
85
|
+
try:
|
|
86
|
+
public_key_b64 = key_manager.export_public_key(name)
|
|
87
|
+
console.print(f"\n[bold]Public key for '{name}':[/bold]")
|
|
88
|
+
console.print(f"[cyan]{public_key_b64}[/cyan]\n")
|
|
89
|
+
except FileNotFoundError as e:
|
|
90
|
+
console.print(f"[red]❌ Error:[/red] {e}")
|
|
91
|
+
raise typer.Exit(1)
|
|
92
|
+
|
|
93
|
+
else:
|
|
94
|
+
console.print(f"[red]❌ Unknown action:[/red] {action}")
|
|
95
|
+
console.print("[dim]Valid actions: generate, list, export[/dim]")
|
|
96
|
+
raise typer.Exit(1)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# Entry point for CLI
|
|
100
|
+
def cli_main():
|
|
101
|
+
"""CLI entry point (called by `epi` command)."""
|
|
102
|
+
app()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
if __name__ == "__main__":
|
|
106
|
+
cli_main()
|
epi_cli/record.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""
|
|
2
|
+
EPI CLI Record - Capture AI workflow into a portable .epi file.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
epi record --out run.epi -- python script.py [args...]
|
|
6
|
+
|
|
7
|
+
This command:
|
|
8
|
+
- Prepares a recording workspace
|
|
9
|
+
- Patches LLM libraries in the child process
|
|
10
|
+
- Captures environment snapshot (env.json)
|
|
11
|
+
- Runs the user command with secret redaction enabled by default
|
|
12
|
+
- Packages everything into a .epi
|
|
13
|
+
- Auto-signs the manifest with the default Ed25519 key
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import shlex
|
|
18
|
+
import sys
|
|
19
|
+
import tempfile
|
|
20
|
+
import time
|
|
21
|
+
import zipfile
|
|
22
|
+
from datetime import datetime
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import List, Optional
|
|
25
|
+
|
|
26
|
+
import typer
|
|
27
|
+
from rich.console import Console
|
|
28
|
+
from rich.panel import Panel
|
|
29
|
+
|
|
30
|
+
from epi_core.container import EPIContainer
|
|
31
|
+
from epi_core.schemas import ManifestModel
|
|
32
|
+
from epi_core.trust import sign_manifest, sign_manifest_inplace
|
|
33
|
+
from epi_cli.keys import KeyManager
|
|
34
|
+
from epi_recorder.environment import save_environment_snapshot
|
|
35
|
+
|
|
36
|
+
console = Console()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _ensure_python_command(cmd: List[str]) -> List[str]:
|
|
40
|
+
"""
|
|
41
|
+
Ensure the command is run with Python if it looks like a Python script.
|
|
42
|
+
If the user provided `python ...`, leave as-is.
|
|
43
|
+
If the command is a .py file, prepend current Python executable.
|
|
44
|
+
"""
|
|
45
|
+
if not cmd:
|
|
46
|
+
return cmd
|
|
47
|
+
first = cmd[0]
|
|
48
|
+
if first.lower().endswith('.py'):
|
|
49
|
+
return [sys.executable] + cmd
|
|
50
|
+
return cmd
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _build_env_for_child(steps_dir: Path, enable_redaction: bool) -> dict:
|
|
54
|
+
"""
|
|
55
|
+
Build environment variables for child process to enable recording via sitecustomize.
|
|
56
|
+
"""
|
|
57
|
+
env = os.environ.copy()
|
|
58
|
+
|
|
59
|
+
# Indicate recording mode and where to write steps
|
|
60
|
+
env["EPI_RECORD"] = "1"
|
|
61
|
+
env["EPI_STEPS_DIR"] = str(steps_dir)
|
|
62
|
+
env["EPI_REDACT"] = "1" if enable_redaction else "0"
|
|
63
|
+
|
|
64
|
+
# Create a temporary bootstrap dir with sitecustomize.py
|
|
65
|
+
bootstrap_dir = Path(tempfile.mkdtemp(prefix="epi_bootstrap_"))
|
|
66
|
+
sitecustomize = bootstrap_dir / "sitecustomize.py"
|
|
67
|
+
sitecustomize.write_text(
|
|
68
|
+
"from epi_recorder.bootstrap import initialize_recording\n",
|
|
69
|
+
encoding="utf-8",
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# Prepend bootstrap dir and project root to PYTHONPATH
|
|
73
|
+
project_root = Path(__file__).resolve().parent.parent
|
|
74
|
+
existing = env.get("PYTHONPATH", "")
|
|
75
|
+
sep = os.pathsep
|
|
76
|
+
env["PYTHONPATH"] = f"{bootstrap_dir}{sep}{project_root}{(sep + existing) if existing else ''}"
|
|
77
|
+
|
|
78
|
+
return env
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
app = typer.Typer(name="record", help="Record a workflow into a .epi file")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@app.callback(invoke_without_command=True)
|
|
85
|
+
def record(
|
|
86
|
+
ctx: typer.Context,
|
|
87
|
+
out: Path = typer.Option(..., "--out", help="Output .epi file path"),
|
|
88
|
+
name: Optional[str] = typer.Option(None, "--name", help="Optional run name"),
|
|
89
|
+
tag: Optional[str] = typer.Option(None, "--tag", help="Optional tag/label"),
|
|
90
|
+
no_sign: bool = typer.Option(False, "--no-sign", help="Do not sign the manifest"),
|
|
91
|
+
no_redact: bool = typer.Option(False, "--no-redact", help="Disable secret redaction"),
|
|
92
|
+
include_all_env: bool = typer.Option(False, "--include-all-env", help="Capture all env vars (redacted)"),
|
|
93
|
+
command: List[str] = typer.Argument(..., help="Command to execute after --"),
|
|
94
|
+
):
|
|
95
|
+
"""
|
|
96
|
+
Record a command and package the run into a .epi file.
|
|
97
|
+
"""
|
|
98
|
+
if not command:
|
|
99
|
+
console.print("[red]❌ No command provided[/red]")
|
|
100
|
+
raise typer.Exit(1)
|
|
101
|
+
|
|
102
|
+
# Normalize command
|
|
103
|
+
cmd = _ensure_python_command(command)
|
|
104
|
+
|
|
105
|
+
# Prepare workspace
|
|
106
|
+
temp_workspace = Path(tempfile.mkdtemp(prefix="epi_record_"))
|
|
107
|
+
steps_dir = temp_workspace # steps.jsonl lives here
|
|
108
|
+
env_json = temp_workspace / "env.json"
|
|
109
|
+
|
|
110
|
+
# Capture environment snapshot
|
|
111
|
+
save_environment_snapshot(env_json, include_all_env_vars=include_all_env, redact_env_vars=True)
|
|
112
|
+
|
|
113
|
+
# Build child environment and run
|
|
114
|
+
child_env = _build_env_for_child(steps_dir, enable_redaction=(not no_redact))
|
|
115
|
+
|
|
116
|
+
# Create stdout/stderr logs
|
|
117
|
+
stdout_log = temp_workspace / "stdout.log"
|
|
118
|
+
stderr_log = temp_workspace / "stderr.log"
|
|
119
|
+
|
|
120
|
+
console.print(f"[dim]Recording:[/dim] {' '.join(shlex.quote(c) for c in cmd)}")
|
|
121
|
+
|
|
122
|
+
import subprocess
|
|
123
|
+
|
|
124
|
+
start = time.time()
|
|
125
|
+
with open(stdout_log, "wb") as out_f, open(stderr_log, "wb") as err_f:
|
|
126
|
+
proc = subprocess.Popen(cmd, env=child_env, stdout=out_f, stderr=err_f)
|
|
127
|
+
rc = proc.wait()
|
|
128
|
+
duration = round(time.time() - start, 3)
|
|
129
|
+
|
|
130
|
+
# Build manifest
|
|
131
|
+
manifest = ManifestModel(
|
|
132
|
+
cli_command=" ".join(shlex.quote(c) for c in cmd),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
# Package into .epi
|
|
136
|
+
out = out if str(out).endswith(".epi") else out.with_suffix(".epi")
|
|
137
|
+
EPIContainer.pack(temp_workspace, manifest, out)
|
|
138
|
+
|
|
139
|
+
# Auto-sign manifest inside .epi (without re-packing)
|
|
140
|
+
signed = False
|
|
141
|
+
if not no_sign:
|
|
142
|
+
try:
|
|
143
|
+
km = KeyManager()
|
|
144
|
+
priv = km.load_private_key("default")
|
|
145
|
+
|
|
146
|
+
# Read manifest from ZIP
|
|
147
|
+
import json as _json
|
|
148
|
+
with zipfile.ZipFile(out, "r") as zf:
|
|
149
|
+
raw = zf.read("manifest.json").decode("utf-8")
|
|
150
|
+
data = _json.loads(raw)
|
|
151
|
+
|
|
152
|
+
# Sign manifest
|
|
153
|
+
from epi_core.schemas import ManifestModel as _MM
|
|
154
|
+
from epi_core.trust import sign_manifest as _sign
|
|
155
|
+
m = _MM(**data)
|
|
156
|
+
sm = _sign(m, priv, "default")
|
|
157
|
+
signed_json = sm.model_dump_json(indent=2)
|
|
158
|
+
|
|
159
|
+
# Replace manifest in ZIP (avoid duplicate)
|
|
160
|
+
temp_zip = out.with_suffix(".epi.tmp")
|
|
161
|
+
with zipfile.ZipFile(out, "r") as zf_in:
|
|
162
|
+
with zipfile.ZipFile(temp_zip, "w", zipfile.ZIP_DEFLATED) as zf_out:
|
|
163
|
+
# Copy all files except manifest.json
|
|
164
|
+
for item in zf_in.namelist():
|
|
165
|
+
if item != "manifest.json":
|
|
166
|
+
zf_out.writestr(item, zf_in.read(item))
|
|
167
|
+
# Write signed manifest
|
|
168
|
+
zf_out.writestr("manifest.json", signed_json)
|
|
169
|
+
|
|
170
|
+
# Replace original with updated ZIP
|
|
171
|
+
temp_zip.replace(out)
|
|
172
|
+
signed = True
|
|
173
|
+
except Exception as e:
|
|
174
|
+
console.print(f"[yellow]⚠️ Signing failed:[/yellow] {e}")
|
|
175
|
+
|
|
176
|
+
# Final output panel
|
|
177
|
+
size_mb = out.stat().st_size / (1024 * 1024)
|
|
178
|
+
title = "✅ Recording complete" if rc == 0 else "⚠️ Recording finished with errors"
|
|
179
|
+
panel = Panel(
|
|
180
|
+
f"[bold]File:[/bold] {out}\n"
|
|
181
|
+
f"[bold]Size:[/bold] {size_mb:.1f} MB\n"
|
|
182
|
+
f"[bold]Duration:[/bold] {duration}s\n"
|
|
183
|
+
f"[bold]Exit code:[/bold] {rc}\n"
|
|
184
|
+
f"[bold]Signed:[/bold] {'Yes' if signed else 'No'}\n"
|
|
185
|
+
f"[dim]Verify:[/dim] epi verify {shlex.quote(str(out))}",
|
|
186
|
+
title=title,
|
|
187
|
+
border_style="green" if rc == 0 else "yellow",
|
|
188
|
+
)
|
|
189
|
+
console.print(panel)
|
|
190
|
+
|
|
191
|
+
# Exit with child return code
|
|
192
|
+
raise typer.Exit(rc)
|