vallignus 0.4.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.
vallignus/cli.py ADDED
@@ -0,0 +1,780 @@
1
+ """Click-based CLI entry point - Sprint 2 P0"""
2
+
3
+ import click
4
+ import json
5
+ import os
6
+ import signal
7
+ import subprocess
8
+ import sys
9
+ import time
10
+ from typing import List, Optional
11
+
12
+ from rich.console import Console
13
+ from rich.panel import Panel
14
+ from rich.table import Table
15
+ from rich.text import Text
16
+
17
+ from vallignus.proxy import VallignusProxy
18
+ from vallignus.logger import FlightLogger
19
+ from vallignus.rules import RulesEngine
20
+ from vallignus import auth
21
+ from vallignus import sessions
22
+
23
+
24
+ console = Console()
25
+ _subprocess: Optional[subprocess.Popen] = None
26
+
27
+
28
+ def parse_domains(domains_str: str) -> set:
29
+ """Parse comma-separated domains string into a set"""
30
+ domains = [d.strip().lower() for d in domains_str.split(',') if d.strip()]
31
+ return set(domains)
32
+
33
+
34
+ def create_status_table(proxy: VallignusProxy, rules: RulesEngine, token_payload=None) -> Table:
35
+ """Create a rich table showing proxy status"""
36
+ table = Table(show_header=True, header_style="bold magenta")
37
+ table.add_column("Metric", style="cyan")
38
+ table.add_column("Value", style="green")
39
+
40
+ spend, budget, remaining = rules.get_budget_status()
41
+
42
+ if token_payload:
43
+ table.add_row("Agent", token_payload.agent_id)
44
+ table.add_row("Owner", token_payload.owner)
45
+ table.add_row("Policy", f"{token_payload.policy_id} v{token_payload.policy_version}")
46
+ table.add_row("Token ID", token_payload.jti[:8] + "..." if token_payload.jti else "N/A")
47
+ table.add_row("─" * 10, "─" * 15)
48
+
49
+ table.add_row("Status", "🟢 Running" if proxy.is_running else "🔴 Stopped")
50
+ table.add_row("Allowed Requests", str(proxy.allowed_count))
51
+ table.add_row("Blocked Requests", str(proxy.blocked_count))
52
+
53
+ if budget is not None:
54
+ table.add_row("Budget", f"${budget:.2f}")
55
+ table.add_row("Spent", f"${spend:.4f}")
56
+ table.add_row("Remaining", f"${remaining:.2f}" if remaining is not None else "N/A")
57
+
58
+ if remaining is not None:
59
+ percent_used = (spend / budget) * 100 if budget > 0 else 0
60
+ if percent_used >= 100:
61
+ status_text = Text("⚠️ EXCEEDED", style="bold red")
62
+ elif percent_used >= 80:
63
+ status_text = Text(f"⚠️ {percent_used:.1f}%", style="bold yellow")
64
+ else:
65
+ status_text = Text(f"✓ {percent_used:.1f}%", style="green")
66
+ table.add_row("Budget Usage", status_text)
67
+ else:
68
+ table.add_row("Budget", "Unlimited")
69
+ table.add_row("Spent", f"${spend:.4f}")
70
+
71
+ return table
72
+
73
+
74
+ @click.group()
75
+ def cli():
76
+ """Vallignus - Infrastructure-grade firewall for AI agents"""
77
+ pass
78
+
79
+
80
+ @cli.group()
81
+ def auth_cmd():
82
+ """Manage agent identities, policies, and tokens"""
83
+ pass
84
+
85
+ cli.add_command(auth_cmd, name='auth')
86
+
87
+
88
+ @auth_cmd.command('init')
89
+ def auth_init():
90
+ """Initialize Vallignus auth (creates ~/.vallignus directories and keyring)"""
91
+ success, message = auth.init_auth()
92
+ if success:
93
+ console.print(f"[green]✓[/green] {message}")
94
+ else:
95
+ console.print(f"[red]✗[/red] {message}")
96
+ sys.exit(1)
97
+
98
+
99
+ @auth_cmd.command('create-agent')
100
+ @click.option('--agent-id', required=True, help='Unique identifier for the agent')
101
+ @click.option('--owner', required=True, help='Owner of the agent')
102
+ @click.option('--description', default='', help='Optional description')
103
+ def auth_create_agent(agent_id: str, owner: str, description: str):
104
+ """Create a new agent identity"""
105
+ success, message = auth.create_agent(agent_id, owner, description)
106
+ if success:
107
+ console.print(f"[green]✓[/green] {message}")
108
+ else:
109
+ console.print(f"[red]✗[/red] {message}")
110
+ sys.exit(1)
111
+
112
+
113
+ @auth_cmd.command('list-agents')
114
+ def auth_list_agents():
115
+ """List all registered agents"""
116
+ agents = auth.list_agents()
117
+ if not agents:
118
+ console.print("[yellow]No agents registered[/yellow]")
119
+ return
120
+
121
+ table = Table(show_header=True, header_style="bold cyan")
122
+ table.add_column("Agent ID")
123
+ table.add_column("Owner")
124
+ table.add_column("Description")
125
+
126
+ for a in agents:
127
+ table.add_row(a["agent_id"], a["owner"], a.get("description", ""))
128
+
129
+ console.print(table)
130
+
131
+
132
+ @auth_cmd.command('create-policy')
133
+ @click.option('--policy-id', required=True, help='Unique identifier for the policy')
134
+ @click.option('--max-spend-usd', type=float, default=None, help='Maximum spend in USD')
135
+ @click.option('--allowed-domains', required=True, help='Comma-separated list of allowed domains')
136
+ @click.option('--description', default='', help='Optional description')
137
+ def auth_create_policy(policy_id: str, max_spend_usd: Optional[float], allowed_domains: str, description: str):
138
+ """Create a new permission policy (v1)"""
139
+ success, message = auth.create_policy(policy_id, max_spend_usd, allowed_domains, description)
140
+ if success:
141
+ console.print(f"[green]✓[/green] {message}")
142
+ else:
143
+ console.print(f"[red]✗[/red] {message}")
144
+ sys.exit(1)
145
+
146
+
147
+ @auth_cmd.command('update-policy')
148
+ @click.option('--policy-id', required=True, help='Policy to update')
149
+ @click.option('--max-spend-usd', type=float, default=None, help='New maximum spend in USD')
150
+ @click.option('--allowed-domains', default=None, help='New comma-separated list of allowed domains')
151
+ @click.option('--description', default=None, help='New description')
152
+ def auth_update_policy(policy_id: str, max_spend_usd: Optional[float], allowed_domains: Optional[str], description: Optional[str]):
153
+ """Update a policy (creates new version)"""
154
+ if max_spend_usd is None and allowed_domains is None and description is None:
155
+ console.print("[red]✗[/red] At least one field must be updated")
156
+ sys.exit(1)
157
+
158
+ success, message = auth.update_policy(policy_id, max_spend_usd, allowed_domains, description)
159
+ if success:
160
+ console.print(f"[green]✓[/green] {message}")
161
+ else:
162
+ console.print(f"[red]✗[/red] {message}")
163
+ sys.exit(1)
164
+
165
+
166
+ @auth_cmd.command('list-policies')
167
+ def auth_list_policies():
168
+ """List all registered policies"""
169
+ policies = auth.list_policies()
170
+ if not policies:
171
+ console.print("[yellow]No policies registered[/yellow]")
172
+ return
173
+
174
+ table = Table(show_header=True, header_style="bold cyan")
175
+ table.add_column("Policy ID")
176
+ table.add_column("Version")
177
+ table.add_column("Max Spend")
178
+ table.add_column("Domains")
179
+
180
+ for p in policies:
181
+ spend = f"${p['max_spend_usd']:.2f}" if p.get("max_spend_usd") else "Unlimited"
182
+ domains = ", ".join(p["allowed_domains"][:3])
183
+ if len(p["allowed_domains"]) > 3:
184
+ domains += f" (+{len(p['allowed_domains']) - 3} more)"
185
+ table.add_row(p["policy_id"], f"v{p.get('version', 1)}", spend, domains)
186
+
187
+ console.print(table)
188
+
189
+
190
+ @auth_cmd.command('issue-token')
191
+ @click.option('--agent-id', required=True, help='Agent to issue token for')
192
+ @click.option('--policy-id', required=True, help='Policy to bind to token')
193
+ @click.option('--ttl-seconds', type=int, default=3600, help='Token TTL in seconds')
194
+ def auth_issue_token(agent_id: str, policy_id: str, ttl_seconds: int):
195
+ """Issue a signed token for an agent with a specific policy"""
196
+ try:
197
+ token = auth.issue_token(agent_id, policy_id, ttl_seconds)
198
+ print(token)
199
+ except auth.AuthError as e:
200
+ console.print(f"[red]✗[/red] {e}")
201
+ sys.exit(1)
202
+
203
+
204
+ @auth_cmd.command('verify-token')
205
+ @click.argument('token')
206
+ def auth_verify_token(token: str):
207
+ """Verify a token and display its contents"""
208
+ try:
209
+ payload, policy = auth.verify_token_with_policy(token)
210
+ console.print("[green]✓ Token valid[/green]\n")
211
+
212
+ table = Table(show_header=False)
213
+ table.add_column("Field", style="cyan")
214
+ table.add_column("Value")
215
+
216
+ table.add_row("Agent ID", payload.agent_id)
217
+ table.add_row("Owner", payload.owner)
218
+ table.add_row("Policy", f"{payload.policy_id} v{payload.policy_version}")
219
+ table.add_row("Token ID (jti)", payload.jti)
220
+ table.add_row("Expires", time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(payload.expires_at)))
221
+ table.add_row("─" * 10, "─" * 30)
222
+ table.add_row("Max Spend", f"${policy.max_spend_usd:.2f}" if policy.max_spend_usd else "Unlimited")
223
+ table.add_row("Domains", ", ".join(sorted(policy.allowed_domains)))
224
+
225
+ console.print(table)
226
+ except auth.AuthError as e:
227
+ console.print(f"[red]✗ {e}[/red]")
228
+ sys.exit(1)
229
+
230
+
231
+ @auth_cmd.command('inspect-token')
232
+ @click.argument('token')
233
+ def auth_inspect_token(token: str):
234
+ """Decode token WITHOUT verification (for debugging)"""
235
+ try:
236
+ decoded = auth.decode_token_payload(token)
237
+ console.print("[yellow]⚠ Decoded (NOT verified)[/yellow]\n")
238
+ console.print("[bold]Header:[/bold]")
239
+ console.print(json.dumps(decoded["header"], indent=2))
240
+ console.print("\n[bold]Payload:[/bold]")
241
+ console.print(json.dumps(decoded["payload"], indent=2))
242
+ except auth.AuthError as e:
243
+ console.print(f"[red]✗ {e}[/red]")
244
+ sys.exit(1)
245
+
246
+
247
+ @auth_cmd.command('revoke-token')
248
+ @click.option('--jti', required=True, help='Token ID (jti) to revoke')
249
+ def auth_revoke_token(jti: str):
250
+ """Revoke a token by its JTI"""
251
+ success, message = auth.revoke_token(jti)
252
+ if success:
253
+ console.print(f"[green]✓[/green] {message}")
254
+ else:
255
+ console.print(f"[red]✗[/red] {message}")
256
+ sys.exit(1)
257
+
258
+
259
+ @auth_cmd.command('rotate-key')
260
+ def auth_rotate_key():
261
+ """Generate a new signing key (old tokens remain valid)"""
262
+ success, message = auth.rotate_key()
263
+ if success:
264
+ console.print(f"[green]✓[/green] {message}")
265
+ else:
266
+ console.print(f"[red]✗[/red] {message}")
267
+ sys.exit(1)
268
+
269
+
270
+ # =============================================================================
271
+ # SESSIONS COMMANDS
272
+ # =============================================================================
273
+
274
+ @cli.group()
275
+ def sessions_cmd():
276
+ """Manage agent sessions and replay"""
277
+ pass
278
+
279
+ cli.add_command(sessions_cmd, name='sessions')
280
+
281
+
282
+ @sessions_cmd.command('list')
283
+ @click.option('--limit', type=int, default=20, help='Maximum number of sessions to show')
284
+ def sessions_list(limit: int):
285
+ """List recent sessions"""
286
+ session_list = sessions.SessionManager.list_sessions(limit)
287
+
288
+ if not session_list:
289
+ console.print("[yellow]No sessions found[/yellow]")
290
+ return
291
+
292
+ table = Table(show_header=True, header_style="bold cyan")
293
+ table.add_column("Session ID", style="cyan")
294
+ table.add_column("Exit", justify="center")
295
+ table.add_column("Duration", justify="right")
296
+ table.add_column("Command")
297
+
298
+ for s in session_list:
299
+ # Format exit code with color
300
+ if s.exit_code is None:
301
+ exit_str = Text("...", style="yellow")
302
+ elif s.exit_code == 0:
303
+ exit_str = Text("0", style="green")
304
+ else:
305
+ exit_str = Text(str(s.exit_code), style="red")
306
+
307
+ # Format duration
308
+ if s.duration_ms is not None:
309
+ if s.duration_ms < 1000:
310
+ duration_str = f"{s.duration_ms}ms"
311
+ elif s.duration_ms < 60000:
312
+ duration_str = f"{s.duration_ms/1000:.1f}s"
313
+ else:
314
+ duration_str = f"{s.duration_ms/60000:.1f}m"
315
+ else:
316
+ duration_str = "-"
317
+
318
+ # Format command (truncate if too long)
319
+ cmd_str = ' '.join(s.command)
320
+ if len(cmd_str) > 50:
321
+ cmd_str = cmd_str[:47] + "..."
322
+
323
+ table.add_row(s.session_id, exit_str, duration_str, cmd_str)
324
+
325
+ console.print(table)
326
+
327
+
328
+ @sessions_cmd.command('show')
329
+ @click.argument('session_id')
330
+ @click.option('--events', type=int, default=20, help='Number of events to show')
331
+ def sessions_show(session_id: str, events: int):
332
+ """Show session details and recent events"""
333
+ metadata = sessions.SessionManager.load(session_id)
334
+
335
+ if not metadata:
336
+ console.print(f"[red]Session not found: {session_id}[/red]")
337
+ sys.exit(1)
338
+
339
+ # Session metadata table
340
+ console.print(f"\n[bold cyan]Session: {session_id}[/bold cyan]\n")
341
+
342
+ table = Table(show_header=False, box=None)
343
+ table.add_column("Field", style="cyan")
344
+ table.add_column("Value")
345
+
346
+ table.add_row("Started", metadata.started_at_iso)
347
+ if metadata.finished_at_iso:
348
+ table.add_row("Finished", metadata.finished_at_iso)
349
+ table.add_row("Command", ' '.join(metadata.command))
350
+ table.add_row("CWD", metadata.cwd)
351
+
352
+ if metadata.exit_code is not None:
353
+ exit_style = "green" if metadata.exit_code == 0 else "red"
354
+ table.add_row("Exit Code", Text(str(metadata.exit_code), style=exit_style))
355
+ else:
356
+ table.add_row("Exit Code", Text("running...", style="yellow"))
357
+
358
+ if metadata.duration_ms is not None:
359
+ table.add_row("Duration", f"{metadata.duration_ms}ms ({metadata.duration_ms/1000:.2f}s)")
360
+
361
+ table.add_row("Stdout Lines", str(metadata.stdout_lines))
362
+ table.add_row("Stderr Lines", str(metadata.stderr_lines))
363
+
364
+ # Sprint 1: Show termination info if present
365
+ if metadata.termination_reason:
366
+ table.add_row("", "") # Spacer
367
+ table.add_row("Termination", Text(metadata.termination_reason, style="red bold"))
368
+ if metadata.termination_limit_value is not None:
369
+ table.add_row(" Limit", str(metadata.termination_limit_value))
370
+ if metadata.termination_observed_value is not None:
371
+ table.add_row(" Observed", str(metadata.termination_observed_value))
372
+
373
+ # Sprint 1: Show request counts if present (firewall mode)
374
+ if metadata.total_requests is not None:
375
+ table.add_row("", "") # Spacer
376
+ table.add_row("Total Requests", str(metadata.total_requests))
377
+ table.add_row(" Allowed", Text(str(metadata.allowed_requests or 0), style="green"))
378
+ table.add_row(" Denied", Text(str(metadata.denied_requests or 0), style="red"))
379
+
380
+ console.print(table)
381
+
382
+ # Show recent events
383
+ all_events = sessions.SessionManager.load_events(session_id)
384
+
385
+ if all_events:
386
+ console.print(f"\n[bold]Last {min(events, len(all_events))} events:[/bold]\n")
387
+
388
+ # Get last N events
389
+ recent_events = all_events[-events:] if len(all_events) > events else all_events
390
+
391
+ for event in recent_events:
392
+ event_type = event.get('type', 'unknown')
393
+ ts_ms = event.get('ts_ms', 0)
394
+
395
+ # Format timestamp relative to first event
396
+ if all_events:
397
+ start_ts = all_events[0].get('ts_ms', ts_ms)
398
+ relative = (ts_ms - start_ts) / 1000
399
+ ts_str = f"[dim]+{relative:.3f}s[/dim]"
400
+ else:
401
+ ts_str = ""
402
+
403
+ if event_type == 'stdout_line':
404
+ line = event.get('line', '')
405
+ console.print(f" {ts_str} [green]stdout[/green]: {line}")
406
+ elif event_type == 'stderr_line':
407
+ line = event.get('line', '')
408
+ console.print(f" {ts_str} [red]stderr[/red]: {line}")
409
+ elif event_type == 'run_started':
410
+ console.print(f" {ts_str} [cyan]run_started[/cyan]")
411
+ elif event_type == 'process_started':
412
+ console.print(f" {ts_str} [cyan]process_started[/cyan]")
413
+ elif event_type == 'process_exited':
414
+ exit_code = event.get('exit_code', '?')
415
+ console.print(f" {ts_str} [cyan]process_exited[/cyan] (code={exit_code})")
416
+ elif event_type == 'run_finished':
417
+ duration = event.get('duration_ms', 0)
418
+ console.print(f" {ts_str} [cyan]run_finished[/cyan] (duration={duration}ms)")
419
+ elif event_type == 'run_terminated':
420
+ reason = event.get('reason', '?')
421
+ limit_val = event.get('limit_value', '?')
422
+ observed_val = event.get('observed_value', '?')
423
+ console.print(f" {ts_str} [red bold]run_terminated[/red bold] reason={reason} limit={limit_val} observed={observed_val}")
424
+ else:
425
+ console.print(f" {ts_str} [dim]{event_type}[/dim]")
426
+
427
+
428
+ @cli.command()
429
+ @click.argument('session_id')
430
+ @click.option('--no-timestamps', is_flag=True, help='Hide timestamps')
431
+ def replay(session_id: str, no_timestamps: bool):
432
+ """Replay a session's output to console"""
433
+ try:
434
+ all_events = sessions.SessionManager.load_events(session_id)
435
+
436
+ if not all_events:
437
+ console.print(f"[red]No events found for session: {session_id}[/red]")
438
+ sys.exit(1)
439
+
440
+ metadata = sessions.SessionManager.load(session_id)
441
+ if metadata:
442
+ console.print(f"[dim]Replaying session: {session_id}[/dim]")
443
+ console.print(f"[dim]Command: {' '.join(metadata.command)}[/dim]")
444
+ console.print()
445
+
446
+ start_ts = all_events[0].get('ts_ms', 0) if all_events else 0
447
+
448
+ for event in all_events:
449
+ event_type = event.get('type', '')
450
+ ts_ms = event.get('ts_ms', 0)
451
+ relative_s = (ts_ms - start_ts) / 1000
452
+
453
+ if event_type == 'stdout_line':
454
+ line = event.get('line', '')
455
+ if no_timestamps:
456
+ print(line)
457
+ else:
458
+ print(f"[{relative_s:>7.3f}s] {line}")
459
+
460
+ elif event_type == 'stderr_line':
461
+ line = event.get('line', '')
462
+ if no_timestamps:
463
+ console.print(f"[red]{line}[/red]")
464
+ else:
465
+ console.print(f"[dim][{relative_s:>7.3f}s][/dim] [red]{line}[/red]")
466
+
467
+ console.print()
468
+ if metadata and metadata.exit_code is not None:
469
+ style = "green" if metadata.exit_code == 0 else "red"
470
+ console.print(f"[dim]Exit code:[/dim] [{style}]{metadata.exit_code}[/{style}]")
471
+ if metadata and metadata.duration_ms is not None:
472
+ console.print(f"[dim]Duration:[/dim] {metadata.duration_ms}ms")
473
+
474
+ except Exception as e:
475
+ console.print(f"[red]Error replaying session: {e}[/red]")
476
+ sys.exit(1)
477
+
478
+
479
+ @cli.command()
480
+ @click.option('--token', envvar='VALLIGNUS_TOKEN', required=False, help='Auth token (optional, enables firewall)')
481
+ @click.option('--port', type=int, default=8080, help='Proxy port (firewall mode)')
482
+ @click.option('--log', type=str, default='flight_log.json', help='Firewall log file path')
483
+ @click.option('--no-session', is_flag=True, help='Disable session logging')
484
+ @click.option('--max-runtime', type=int, default=None, help='Maximum runtime in seconds before termination')
485
+ @click.option('--max-output-lines', type=int, default=None, help='Maximum stdout+stderr lines before termination')
486
+ @click.option('--max-requests', type=int, default=None, help='Maximum HTTP requests before termination (firewall mode only)')
487
+ @click.argument('command', nargs=-1, required=True)
488
+ def run(token: Optional[str], port: int, log: str, no_session: bool,
489
+ max_runtime: Optional[int], max_output_lines: Optional[int],
490
+ max_requests: Optional[int], command: List[str]):
491
+ """
492
+ Run a command with session logging (and optional firewall).
493
+
494
+ Without --token: Creates a session with logs and replay support.
495
+
496
+ With --token: Also enforces firewall policies via proxy.
497
+
498
+ Limits (all optional):
499
+
500
+ --max-runtime: Kill process after N seconds
501
+
502
+ --max-output-lines: Kill after N total output lines
503
+
504
+ --max-requests: Kill after N HTTP requests (firewall mode only)
505
+ """
506
+ global _subprocess
507
+
508
+ if not command:
509
+ console.print("[red]Error: Command is required[/red]")
510
+ sys.exit(1)
511
+
512
+ # Warn if --max-requests used without firewall mode
513
+ if max_requests is not None and token is None:
514
+ console.print("[yellow]Warning: --max-requests only works in firewall mode (with --token)[/yellow]")
515
+ max_requests = None
516
+
517
+ # Initialize session
518
+ session_manager = None
519
+ if not no_session:
520
+ session_manager = sessions.SessionManager()
521
+ session_manager.create(list(command))
522
+ console.print(f"[dim]Session: {session_manager.session_id}[/dim]")
523
+
524
+ # Print limits if set
525
+ limits_active = []
526
+ if max_runtime:
527
+ limits_active.append(f"runtime={max_runtime}s")
528
+ if max_output_lines:
529
+ limits_active.append(f"output={max_output_lines} lines")
530
+ if max_requests:
531
+ limits_active.append(f"requests={max_requests}")
532
+ if limits_active:
533
+ console.print(f"[dim]Limits: {', '.join(limits_active)}[/dim]")
534
+
535
+ # Check if firewall mode (token provided)
536
+ firewall_mode = token is not None
537
+ token_payload = None
538
+ policy = None
539
+ proxy = None
540
+ rules = None
541
+
542
+ if firewall_mode:
543
+ try:
544
+ token_payload, policy = auth.verify_token_with_policy(token)
545
+ except auth.AuthError as e:
546
+ if session_manager:
547
+ session_manager.finish(-1)
548
+ if "TOKEN_REVOKED" in str(e):
549
+ console.print(f"[red]✗ Token has been revoked[/red]")
550
+ else:
551
+ console.print(f"[red]✗ Token error: {e}[/red]")
552
+ sys.exit(1)
553
+
554
+ allowed_domains = policy.allowed_domains
555
+ budget = policy.max_spend_usd
556
+
557
+ console.print(f"[cyan]Agent:[/cyan] {token_payload.agent_id} ({token_payload.owner})")
558
+ console.print(f"[cyan]Policy:[/cyan] {token_payload.policy_id} v{token_payload.policy_version}")
559
+ console.print(f"[cyan]Budget:[/cyan] ${budget:.2f}" if budget else "[cyan]Budget:[/cyan] Unlimited")
560
+ console.print(f"[cyan]Domains:[/cyan] {', '.join(sorted(allowed_domains))}")
561
+ console.print()
562
+
563
+ logger = FlightLogger(
564
+ log_file=log,
565
+ agent_id=token_payload.agent_id,
566
+ owner=token_payload.owner,
567
+ policy_id=token_payload.policy_id,
568
+ policy_version=token_payload.policy_version,
569
+ jti=token_payload.jti
570
+ )
571
+ rules = RulesEngine(allowed_domains, budget)
572
+ proxy = VallignusProxy(allowed_domains, budget, logger, rules)
573
+
574
+ try:
575
+ actual_port = proxy.start(port)
576
+ console.print(f"[green]✓[/green] Proxy started on port {actual_port}")
577
+ except Exception as e:
578
+ if session_manager:
579
+ session_manager.finish(-1)
580
+ console.print(f"[red]Error starting proxy: {e}[/red]")
581
+ sys.exit(1)
582
+
583
+ # Set up environment
584
+ env = os.environ.copy()
585
+ if firewall_mode and proxy:
586
+ actual_port = proxy._port if hasattr(proxy, '_port') else port
587
+ env['HTTP_PROXY'] = f'http://127.0.0.1:{actual_port}'
588
+ env['HTTPS_PROXY'] = f'http://127.0.0.1:{actual_port}'
589
+ env['http_proxy'] = f'http://127.0.0.1:{actual_port}'
590
+ env['https_proxy'] = f'http://127.0.0.1:{actual_port}'
591
+ env['PYTHONWARNINGS'] = 'ignore'
592
+
593
+ console.print(f"[cyan]Starting: {' '.join(command)}[/cyan]")
594
+
595
+ # Shared state for termination
596
+ termination_reason = None
597
+ termination_limit = None
598
+ termination_observed = None
599
+ should_terminate = False
600
+
601
+ try:
602
+ # Start subprocess with output capture for sessions
603
+ _subprocess = subprocess.Popen(
604
+ list(command),
605
+ env=env,
606
+ stdout=subprocess.PIPE,
607
+ stderr=subprocess.PIPE,
608
+ text=True,
609
+ bufsize=1
610
+ )
611
+
612
+ if session_manager:
613
+ session_manager.process_started()
614
+
615
+ # Thread-safe output handling
616
+ import threading
617
+ output_lock = threading.Lock()
618
+
619
+ def read_stdout():
620
+ nonlocal should_terminate, termination_reason, termination_limit, termination_observed
621
+ try:
622
+ for line in iter(_subprocess.stdout.readline, ''):
623
+ if should_terminate:
624
+ break
625
+ line = line.rstrip('\n\r')
626
+ with output_lock:
627
+ print(line)
628
+ sys.stdout.flush()
629
+ if session_manager:
630
+ total_lines = session_manager.log_stdout(line)
631
+ # Check output line limit
632
+ if max_output_lines and total_lines >= max_output_lines and not should_terminate:
633
+ should_terminate = True
634
+ termination_reason = "max_output_lines"
635
+ termination_limit = max_output_lines
636
+ termination_observed = total_lines
637
+ except:
638
+ pass
639
+
640
+ def read_stderr():
641
+ nonlocal should_terminate, termination_reason, termination_limit, termination_observed
642
+ try:
643
+ for line in iter(_subprocess.stderr.readline, ''):
644
+ if should_terminate:
645
+ break
646
+ line = line.rstrip('\n\r')
647
+ with output_lock:
648
+ console.print(f"[red]{line}[/red]")
649
+ if session_manager:
650
+ total_lines = session_manager.log_stderr(line)
651
+ # Check output line limit
652
+ if max_output_lines and total_lines >= max_output_lines and not should_terminate:
653
+ should_terminate = True
654
+ termination_reason = "max_output_lines"
655
+ termination_limit = max_output_lines
656
+ termination_observed = total_lines
657
+ except:
658
+ pass
659
+
660
+ stdout_thread = threading.Thread(target=read_stdout, daemon=True)
661
+ stderr_thread = threading.Thread(target=read_stderr, daemon=True)
662
+ stdout_thread.start()
663
+ stderr_thread.start()
664
+
665
+ except Exception as e:
666
+ if session_manager:
667
+ session_manager.finish(-1)
668
+ console.print(f"[red]Error starting command: {e}[/red]")
669
+ if proxy:
670
+ proxy.stop()
671
+ sys.exit(1)
672
+
673
+ def signal_handler(sig, frame):
674
+ console.print("\n[yellow]Shutting down...[/yellow]")
675
+ if _subprocess:
676
+ _subprocess.terminate()
677
+ if proxy:
678
+ proxy.stop()
679
+ if session_manager:
680
+ session_manager.finish(-15) # SIGTERM
681
+ sys.exit(0)
682
+
683
+ signal.signal(signal.SIGINT, signal_handler)
684
+ signal.signal(signal.SIGTERM, signal_handler)
685
+
686
+ exit_code = 0
687
+ start_time = time.time()
688
+
689
+ try:
690
+ while _subprocess.poll() is None:
691
+ # Check budget exceeded (firewall mode)
692
+ if firewall_mode and proxy and proxy._should_terminate:
693
+ console.print("\n[red]⚠️ Budget exceeded![/red]")
694
+ should_terminate = True
695
+ termination_reason = "budget_exceeded"
696
+
697
+ # Check max runtime
698
+ if max_runtime:
699
+ elapsed = time.time() - start_time
700
+ if elapsed >= max_runtime and not should_terminate:
701
+ should_terminate = True
702
+ termination_reason = "max_runtime"
703
+ termination_limit = max_runtime
704
+ termination_observed = int(elapsed)
705
+
706
+ # Check max requests (firewall mode)
707
+ if max_requests and firewall_mode and proxy:
708
+ total_requests = proxy.allowed_count + proxy.blocked_count
709
+ if total_requests >= max_requests and not should_terminate:
710
+ should_terminate = True
711
+ termination_reason = "max_requests"
712
+ termination_limit = max_requests
713
+ termination_observed = total_requests
714
+
715
+ # Perform termination if needed
716
+ if should_terminate:
717
+ console.print(f"\n[red]⚠️ Terminating: {termination_reason}[/red]")
718
+ if termination_limit:
719
+ console.print(f"[red] Limit: {termination_limit}, Observed: {termination_observed}[/red]")
720
+
721
+ # Record termination in session
722
+ if session_manager and termination_reason and termination_reason != "budget_exceeded":
723
+ session_manager.terminate(termination_reason, termination_limit or 0, termination_observed or 0)
724
+
725
+ # Graceful terminate first
726
+ _subprocess.terminate()
727
+
728
+ # Wait up to 2 seconds for graceful shutdown
729
+ try:
730
+ _subprocess.wait(timeout=2.0)
731
+ except subprocess.TimeoutExpired:
732
+ # Force kill if still running
733
+ console.print("[red] Process did not exit, sending SIGKILL...[/red]")
734
+ _subprocess.kill()
735
+ _subprocess.wait(timeout=1.0)
736
+
737
+ break
738
+
739
+ time.sleep(0.1)
740
+
741
+ # Wait for output threads to finish
742
+ stdout_thread.join(timeout=1.0)
743
+ stderr_thread.join(timeout=1.0)
744
+
745
+ exit_code = _subprocess.returncode or 0
746
+
747
+ # Record request counts from firewall
748
+ if session_manager and firewall_mode and proxy:
749
+ session_manager.set_request_counts(proxy.allowed_count, proxy.blocked_count)
750
+
751
+ # Finalize session
752
+ if session_manager:
753
+ session_manager.finish(exit_code)
754
+ console.print(f"\n[dim]Session saved: {session_manager.session_id}[/dim]")
755
+ if termination_reason:
756
+ console.print(f"[dim]Termination: {termination_reason}[/dim]")
757
+
758
+ # Show firewall summary if in firewall mode
759
+ if firewall_mode and rules:
760
+ status_table = create_status_table(proxy, rules, token_payload)
761
+ console.print(Panel(status_table, title="Vallignus Firewall", border_style="blue"))
762
+ console.print(f"\n[cyan]Flight log saved to: {log}[/cyan]")
763
+
764
+ # Exit with non-zero if terminated
765
+ if termination_reason:
766
+ sys.exit(1)
767
+
768
+ finally:
769
+ if proxy:
770
+ proxy.stop()
771
+ if _subprocess and _subprocess.poll() is None:
772
+ _subprocess.terminate()
773
+
774
+
775
+ def main():
776
+ cli()
777
+
778
+
779
+ if __name__ == '__main__':
780
+ main()