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 ADDED
@@ -0,0 +1,5 @@
1
+ """
2
+ EPI CLI - Command-line interface for EPI operations.
3
+ """
4
+
5
+ __version__ = "1.0.0-keystone"
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)