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/__init__.py +3 -0
- vallignus/auth.py +699 -0
- vallignus/cli.py +780 -0
- vallignus/identity/__init__.py +5 -0
- vallignus/identity/chrome.py +47 -0
- vallignus/identity/manager.py +175 -0
- vallignus/logger.py +86 -0
- vallignus/proxy.py +122 -0
- vallignus/rules.py +90 -0
- vallignus/sessions.py +529 -0
- vallignus-0.4.0.dist-info/METADATA +250 -0
- vallignus-0.4.0.dist-info/RECORD +15 -0
- vallignus-0.4.0.dist-info/WHEEL +5 -0
- vallignus-0.4.0.dist-info/entry_points.txt +2 -0
- vallignus-0.4.0.dist-info/top_level.txt +1 -0
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()
|