krunbox 2.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.
- krunbox/__init__.py +7 -0
- krunbox/__main__.py +5 -0
- krunbox/cli/commands.py +1801 -0
- krunbox/cli/completion.py +614 -0
- krunbox/cli/main.py +547 -0
- krunbox/core/config.py +263 -0
- krunbox/core/desktop.py +108 -0
- krunbox/core/doctor.py +311 -0
- krunbox/core/egress_traffic.py +604 -0
- krunbox/core/host_egress.py +301 -0
- krunbox/core/ide.py +209 -0
- krunbox/core/images.py +662 -0
- krunbox/core/ipc_server.py +854 -0
- krunbox/core/manager.py +1866 -0
- krunbox/core/mcp_gateway.py +15 -0
- krunbox/core/network.py +873 -0
- krunbox/core/notifications.py +615 -0
- krunbox/core/ports.py +147 -0
- krunbox/core/proxy.py +887 -0
- krunbox/core/sandbox.py +159 -0
- krunbox/core/secrets.py +504 -0
- krunbox/core/settings.py +67 -0
- krunbox/core/ssh.py +257 -0
- krunbox/core/telemetry.py +132 -0
- krunbox/core/templates.py +293 -0
- krunbox/core/workspace_diff.py +1227 -0
- krunbox/data/icons/128x128/io.krunbox.Krunbox.png +0 -0
- krunbox/data/icons/16x16/io.krunbox.Krunbox.png +0 -0
- krunbox/data/icons/24x24/io.krunbox.Krunbox.png +0 -0
- krunbox/data/icons/256x256/io.krunbox.Krunbox.png +0 -0
- krunbox/data/icons/32x32/io.krunbox.Krunbox.png +0 -0
- krunbox/data/icons/48x48/io.krunbox.Krunbox.png +0 -0
- krunbox/data/icons/512x512/io.krunbox.Krunbox.png +0 -0
- krunbox/data/icons/64x64/io.krunbox.Krunbox.png +0 -0
- krunbox/data/icons/io.krunbox.Krunbox-symbolic.svg +12 -0
- krunbox/data/icons/io.krunbox.Krunbox.svg +49 -0
- krunbox/data/io.krunbox.Krunbox.desktop +12 -0
- krunbox/data/io.krunbox.Krunbox.metainfo.xml +58 -0
- krunbox/data/krunbox.bash-completion +63 -0
- krunbox/ui/app.py +116 -0
- krunbox/ui/create_dialog.py +1062 -0
- krunbox/ui/dialogs.py +58 -0
- krunbox/ui/doctor_dialog.py +160 -0
- krunbox/ui/edit_dialog.py +709 -0
- krunbox/ui/egress_dialog.py +731 -0
- krunbox/ui/logs_dialog.py +89 -0
- krunbox/ui/mcp_guide_dialog.py +619 -0
- krunbox/ui/mcp_panel.py +626 -0
- krunbox/ui/network_policy_dialog.py +245 -0
- krunbox/ui/port_dialog.py +234 -0
- krunbox/ui/sandbox_row.py +1260 -0
- krunbox/ui/settings_dialog.py +285 -0
- krunbox/ui/telemetry_dialog.py +188 -0
- krunbox/ui/window.py +703 -0
- krunbox/ui/worker.py +42 -0
- krunbox/ui/workspace_diff_dialog.py +575 -0
- krunbox-2.0.0.dist-info/METADATA +249 -0
- krunbox-2.0.0.dist-info/RECORD +63 -0
- krunbox-2.0.0.dist-info/WHEEL +5 -0
- krunbox-2.0.0.dist-info/entry_points.txt +3 -0
- krunbox-2.0.0.dist-info/licenses/LICENSE +202 -0
- krunbox-2.0.0.dist-info/licenses/NOTICE +16 -0
- krunbox-2.0.0.dist-info/top_level.txt +1 -0
krunbox/cli/commands.py
ADDED
|
@@ -0,0 +1,1801 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI command handlers for krunbox with corporate presentation styling.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
from typing import Dict, List, Optional, Tuple
|
|
12
|
+
import webbrowser
|
|
13
|
+
|
|
14
|
+
from ..core.config import DEFAULT_CPUS, DEFAULT_RAM_MIB, NETWORK_PROFILES
|
|
15
|
+
from ..core.doctor import Doctor
|
|
16
|
+
from ..core.images import ImageManager
|
|
17
|
+
from ..core.manager import SandboxManager
|
|
18
|
+
from ..core.notifications import run_with_completion_notification, send_desktop_notification
|
|
19
|
+
from ..core.sandbox import NetworkPolicy, PortMapping, SandboxInfo
|
|
20
|
+
from ..core.secrets import is_valid_env_key, sanitize_env_value
|
|
21
|
+
from ..core.templates import SandboxTemplate
|
|
22
|
+
from rich.console import Console
|
|
23
|
+
from rich.table import Table
|
|
24
|
+
from rich import box as rich_box
|
|
25
|
+
|
|
26
|
+
# Shared Rich Console
|
|
27
|
+
_term_cols = max(shutil.get_terminal_size((120, 40)).columns, 120)
|
|
28
|
+
_term_lines = max(shutil.get_terminal_size((120, 40)).lines, 40)
|
|
29
|
+
console = Console(width=_term_cols, height=_term_lines, highlight=False)
|
|
30
|
+
|
|
31
|
+
# Corporate Terminal Palette (retained for legacy formatting compatibility)
|
|
32
|
+
BOLD = "\033[1m"
|
|
33
|
+
DIM = "\033[2m"
|
|
34
|
+
BLUE = "\033[34m"
|
|
35
|
+
GREEN = "\033[32m"
|
|
36
|
+
YELLOW = "\033[33m"
|
|
37
|
+
CYAN = "\033[36m"
|
|
38
|
+
RED = "\033[31m"
|
|
39
|
+
GRAY = "\033[90m"
|
|
40
|
+
RESET = "\033[0m"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def format_status(status: str) -> str:
|
|
44
|
+
s = status.lower()
|
|
45
|
+
if s in ("running", "up"):
|
|
46
|
+
return "[green]● RUNNING[/green]"
|
|
47
|
+
elif s in ("stopped", "exited"):
|
|
48
|
+
return "[dim]○ STOPPED[/dim]"
|
|
49
|
+
elif s == "paused":
|
|
50
|
+
return "[yellow]⏸ PAUSED[/yellow]"
|
|
51
|
+
return f"[red]? {status.upper()}[/red]"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def format_mode(is_persistent: bool) -> str:
|
|
55
|
+
if is_persistent:
|
|
56
|
+
return "[cyan]PERSISTENT[/cyan]"
|
|
57
|
+
return "[dim]EPHEMERAL[/dim]"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def format_net(profile: str) -> str:
|
|
61
|
+
p = profile.lower()
|
|
62
|
+
if p == "full":
|
|
63
|
+
return "[blue]full[/blue]"
|
|
64
|
+
elif p == "ai-only":
|
|
65
|
+
return "[green]ai-only[/green]"
|
|
66
|
+
elif p == "dev-common":
|
|
67
|
+
return "[cyan]dev-common[/cyan]"
|
|
68
|
+
elif p == "offline":
|
|
69
|
+
return "[red]offline[/red]"
|
|
70
|
+
return f"[yellow]{p}[/yellow]"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def parse_kv_pairs(pairs: Optional[List[str]]) -> Dict[str, str]:
|
|
74
|
+
result: Dict[str, str] = {}
|
|
75
|
+
if not pairs:
|
|
76
|
+
return result
|
|
77
|
+
for pair in pairs:
|
|
78
|
+
if "=" in pair:
|
|
79
|
+
k, v = pair.split("=", 1)
|
|
80
|
+
else:
|
|
81
|
+
k, v = pair, ""
|
|
82
|
+
k = k.strip()
|
|
83
|
+
if is_valid_env_key(k):
|
|
84
|
+
result[k] = sanitize_env_value(v)
|
|
85
|
+
return result
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def cmd_list(manager: SandboxManager, all_info: bool = False, json_output: bool = False) -> int:
|
|
89
|
+
sandboxes = manager.list_sandboxes(refresh_podman=True)
|
|
90
|
+
if json_output:
|
|
91
|
+
import json
|
|
92
|
+
print(json.dumps([b.to_dict() for b in sandboxes], indent=2))
|
|
93
|
+
return 0
|
|
94
|
+
|
|
95
|
+
if not sandboxes:
|
|
96
|
+
console.print("[dim]No active or registered sandboxes. Launch one with `krunbox run [dir]`[/dim]")
|
|
97
|
+
return 0
|
|
98
|
+
|
|
99
|
+
stats = manager.get_stats()
|
|
100
|
+
|
|
101
|
+
table = Table(
|
|
102
|
+
box=rich_box.ROUNDED,
|
|
103
|
+
header_style="bold cyan",
|
|
104
|
+
show_header=True,
|
|
105
|
+
pad_edge=True,
|
|
106
|
+
)
|
|
107
|
+
table.add_column("NAME", style="bold white", no_wrap=True)
|
|
108
|
+
table.add_column("STATUS", no_wrap=True)
|
|
109
|
+
table.add_column("MODE", no_wrap=True)
|
|
110
|
+
table.add_column("NETWORK", no_wrap=True)
|
|
111
|
+
table.add_column("SSH PORT", justify="right", no_wrap=True)
|
|
112
|
+
table.add_column("PORTS")
|
|
113
|
+
table.add_column("DIR")
|
|
114
|
+
if all_info:
|
|
115
|
+
table.add_column("RESOURCES", justify="right")
|
|
116
|
+
|
|
117
|
+
for box_item in sandboxes:
|
|
118
|
+
port_strs = []
|
|
119
|
+
for p in box_item.published_ports:
|
|
120
|
+
port_strs.append(f"{p.host_port}->{p.guest_port}")
|
|
121
|
+
for p in box_item.dynamic_tunnels:
|
|
122
|
+
port_strs.append(f"{p.host_port}->{p.guest_port}(t)")
|
|
123
|
+
ports_display = ", ".join(port_strs) if port_strs else "-"
|
|
124
|
+
if len(ports_display) > 20:
|
|
125
|
+
ports_display = ports_display[:17] + "..."
|
|
126
|
+
|
|
127
|
+
dir_display = box_item.dir_path
|
|
128
|
+
if len(dir_display) > 22:
|
|
129
|
+
dir_display = "..." + dir_display[-19:]
|
|
130
|
+
|
|
131
|
+
stat = stats.get(box_item.name, {})
|
|
132
|
+
row = [
|
|
133
|
+
box_item.name,
|
|
134
|
+
format_status(box_item.status),
|
|
135
|
+
format_mode(box_item.is_persistent),
|
|
136
|
+
format_net(box_item.network_policy.profile),
|
|
137
|
+
str(box_item.ssh_port),
|
|
138
|
+
ports_display,
|
|
139
|
+
dir_display,
|
|
140
|
+
]
|
|
141
|
+
if all_info:
|
|
142
|
+
cpu_val = stat.get('cpu_percent', 0.0)
|
|
143
|
+
row.append(f"{cpu_val:.1f}% CPU ({box_item.cpus}c/{box_item.ram_mib}M)")
|
|
144
|
+
table.add_row(*row)
|
|
145
|
+
|
|
146
|
+
console.print()
|
|
147
|
+
console.print(table)
|
|
148
|
+
console.print(f"\n[dim]Active instances: {len(sandboxes)}[/dim]\n")
|
|
149
|
+
return 0
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _resolve_template_and_params(
|
|
153
|
+
manager: SandboxManager,
|
|
154
|
+
template_id: Optional[str],
|
|
155
|
+
image: Optional[str],
|
|
156
|
+
persistent: Optional[bool],
|
|
157
|
+
network_profile: Optional[str],
|
|
158
|
+
allowed_domains: Optional[List[str]],
|
|
159
|
+
denied_domains: Optional[List[str]],
|
|
160
|
+
ports: Optional[List[str]],
|
|
161
|
+
cpus: Optional[int],
|
|
162
|
+
ram_mib: Optional[int],
|
|
163
|
+
protect_git_hooks: Optional[bool],
|
|
164
|
+
protect_git_config: Optional[bool],
|
|
165
|
+
protect_env_files: Optional[bool],
|
|
166
|
+
host_egress_lockdown: Optional[bool],
|
|
167
|
+
idle_timeout_minutes: Optional[int],
|
|
168
|
+
idle_action: Optional[str],
|
|
169
|
+
) -> Tuple[Optional[SandboxTemplate], dict]:
|
|
170
|
+
tmpl: Optional[SandboxTemplate] = None
|
|
171
|
+
if template_id:
|
|
172
|
+
tmpl = manager.templates.get_template(template_id)
|
|
173
|
+
if not tmpl:
|
|
174
|
+
print(f"{RED}Error: Template '{template_id}' not found.{RESET}", file=sys.stderr)
|
|
175
|
+
return None, {}
|
|
176
|
+
|
|
177
|
+
eff_image = image if image is not None else (tmpl.resolved_image_tag() if tmpl else "localhost/agent-box:latest")
|
|
178
|
+
eff_persistent = persistent if persistent is not None else (tmpl.is_persistent if tmpl else False)
|
|
179
|
+
eff_net_profile = network_profile if network_profile is not None else (tmpl.network_profile if tmpl else "full")
|
|
180
|
+
|
|
181
|
+
eff_allowed = list(allowed_domains or [])
|
|
182
|
+
if tmpl and tmpl.allowed_domains and not allowed_domains:
|
|
183
|
+
eff_allowed = list(tmpl.allowed_domains)
|
|
184
|
+
|
|
185
|
+
eff_denied = list(denied_domains or [])
|
|
186
|
+
if tmpl and tmpl.denied_domains and not denied_domains:
|
|
187
|
+
eff_denied = list(tmpl.denied_domains)
|
|
188
|
+
|
|
189
|
+
eff_ports = list(ports or [])
|
|
190
|
+
if tmpl and tmpl.ports and not ports:
|
|
191
|
+
eff_ports = list(tmpl.ports)
|
|
192
|
+
|
|
193
|
+
eff_cpus = cpus if cpus is not None else (tmpl.cpus if tmpl else DEFAULT_CPUS)
|
|
194
|
+
eff_ram = ram_mib if ram_mib is not None else (tmpl.ram_mib if tmpl else DEFAULT_RAM_MIB)
|
|
195
|
+
|
|
196
|
+
eff_protect_git_hooks = protect_git_hooks if protect_git_hooks is not None else (tmpl.protect_git_hooks if tmpl else True)
|
|
197
|
+
eff_protect_git_config = protect_git_config if protect_git_config is not None else (tmpl.protect_git_config if tmpl else False)
|
|
198
|
+
eff_protect_env_files = protect_env_files if protect_env_files is not None else (tmpl.protect_env_files if tmpl else False)
|
|
199
|
+
eff_host_egress_lockdown = host_egress_lockdown if host_egress_lockdown is not None else (tmpl.host_egress_lockdown if tmpl else False)
|
|
200
|
+
|
|
201
|
+
eff_idle_timeout = idle_timeout_minutes if idle_timeout_minutes is not None else (tmpl.idle_timeout_minutes if tmpl else 0)
|
|
202
|
+
eff_idle_action = idle_action if idle_action is not None else (tmpl.idle_action if tmpl else "stop")
|
|
203
|
+
|
|
204
|
+
params = {
|
|
205
|
+
"image": eff_image,
|
|
206
|
+
"persistent": eff_persistent,
|
|
207
|
+
"network_profile": eff_net_profile,
|
|
208
|
+
"allowed_domains": eff_allowed,
|
|
209
|
+
"denied_domains": eff_denied,
|
|
210
|
+
"ports": eff_ports,
|
|
211
|
+
"cpus": eff_cpus,
|
|
212
|
+
"ram_mib": eff_ram,
|
|
213
|
+
"protect_git_hooks": eff_protect_git_hooks,
|
|
214
|
+
"protect_git_config": eff_protect_git_config,
|
|
215
|
+
"protect_env_files": eff_protect_env_files,
|
|
216
|
+
"host_egress_lockdown": eff_host_egress_lockdown,
|
|
217
|
+
"idle_timeout_minutes": eff_idle_timeout,
|
|
218
|
+
"idle_action": eff_idle_action,
|
|
219
|
+
"template_id": tmpl.id if tmpl else None,
|
|
220
|
+
}
|
|
221
|
+
return tmpl, params
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def cmd_run(
|
|
225
|
+
manager: SandboxManager,
|
|
226
|
+
dir_path: str,
|
|
227
|
+
name: Optional[str] = None,
|
|
228
|
+
template_id: Optional[str] = None,
|
|
229
|
+
image: Optional[str] = None,
|
|
230
|
+
persistent: Optional[bool] = None,
|
|
231
|
+
network_profile: Optional[str] = None,
|
|
232
|
+
allowed_domains: Optional[List[str]] = None,
|
|
233
|
+
denied_domains: Optional[List[str]] = None,
|
|
234
|
+
ports: Optional[List[str]] = None,
|
|
235
|
+
detach: bool = False,
|
|
236
|
+
cpus: Optional[int] = None,
|
|
237
|
+
ram_mib: Optional[int] = None,
|
|
238
|
+
persist_docker: bool = False,
|
|
239
|
+
protect_git_hooks: Optional[bool] = None,
|
|
240
|
+
protect_git_config: Optional[bool] = None,
|
|
241
|
+
protect_env_files: Optional[bool] = None,
|
|
242
|
+
host_egress_lockdown: Optional[bool] = None,
|
|
243
|
+
idle_timeout_minutes: Optional[int] = None,
|
|
244
|
+
idle_action: Optional[str] = None,
|
|
245
|
+
env_pairs: Optional[List[str]] = None,
|
|
246
|
+
pass_env: Optional[List[str]] = None,
|
|
247
|
+
secret_pairs: Optional[List[str]] = None,
|
|
248
|
+
max_tokens_budget: Optional[int] = None,
|
|
249
|
+
max_cost_usd_budget: Optional[float] = None,
|
|
250
|
+
budget_exceeded_action: Optional[str] = None,
|
|
251
|
+
extra_args: Optional[List[str]] = None,
|
|
252
|
+
) -> int:
|
|
253
|
+
resolved_dir = str(Path(dir_path).resolve())
|
|
254
|
+
tmpl, p = _resolve_template_and_params(
|
|
255
|
+
manager=manager,
|
|
256
|
+
template_id=template_id,
|
|
257
|
+
image=image,
|
|
258
|
+
persistent=persistent,
|
|
259
|
+
network_profile=network_profile,
|
|
260
|
+
allowed_domains=allowed_domains,
|
|
261
|
+
denied_domains=denied_domains,
|
|
262
|
+
ports=ports,
|
|
263
|
+
cpus=cpus,
|
|
264
|
+
ram_mib=ram_mib,
|
|
265
|
+
protect_git_hooks=protect_git_hooks,
|
|
266
|
+
protect_git_config=protect_git_config,
|
|
267
|
+
protect_env_files=protect_env_files,
|
|
268
|
+
host_egress_lockdown=host_egress_lockdown,
|
|
269
|
+
idle_timeout_minutes=idle_timeout_minutes,
|
|
270
|
+
idle_action=idle_action,
|
|
271
|
+
)
|
|
272
|
+
if template_id and not tmpl:
|
|
273
|
+
return 1
|
|
274
|
+
|
|
275
|
+
net_policy = NetworkPolicy(
|
|
276
|
+
profile=p["network_profile"],
|
|
277
|
+
allowed_domains=p["allowed_domains"],
|
|
278
|
+
denied_domains=p["denied_domains"],
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
parsed_env = parse_kv_pairs(env_pairs)
|
|
282
|
+
parsed_secrets = parse_kv_pairs(secret_pairs)
|
|
283
|
+
|
|
284
|
+
existing = manager.get_sandbox_for_dir(resolved_dir)
|
|
285
|
+
if existing and (not name or existing.name == name):
|
|
286
|
+
box = existing
|
|
287
|
+
box.is_persistent = p["persistent"]
|
|
288
|
+
box.network_policy = net_policy
|
|
289
|
+
box.cpus = p["cpus"]
|
|
290
|
+
box.ram_mib = p["ram_mib"]
|
|
291
|
+
box.protect_git_hooks = p["protect_git_hooks"]
|
|
292
|
+
box.protect_git_config = p["protect_git_config"]
|
|
293
|
+
box.protect_env_files = p["protect_env_files"]
|
|
294
|
+
box.host_egress_lockdown = p["host_egress_lockdown"]
|
|
295
|
+
box.idle_timeout_minutes = p["idle_timeout_minutes"]
|
|
296
|
+
box.idle_action = p["idle_action"]
|
|
297
|
+
if p.get("template_id"):
|
|
298
|
+
box.template_id = p["template_id"]
|
|
299
|
+
if p["ports"]:
|
|
300
|
+
used = manager._collect_used_host_ports()
|
|
301
|
+
for port_spec in p["ports"]:
|
|
302
|
+
from ..core.ports import parse_port_spec
|
|
303
|
+
mapping = parse_port_spec(port_spec, avoid_ports=used)
|
|
304
|
+
box.published_ports.append(mapping)
|
|
305
|
+
used.add(mapping.host_port)
|
|
306
|
+
if parsed_env:
|
|
307
|
+
box.env_vars.update(parsed_env)
|
|
308
|
+
if pass_env:
|
|
309
|
+
for pk in pass_env:
|
|
310
|
+
if is_valid_env_key(pk) and pk not in box.host_env_passthrough:
|
|
311
|
+
box.host_env_passthrough.append(pk)
|
|
312
|
+
if max_tokens_budget is not None:
|
|
313
|
+
box.max_tokens_budget = max(0, int(max_tokens_budget))
|
|
314
|
+
if max_cost_usd_budget is not None:
|
|
315
|
+
box.max_cost_usd_budget = max(0.0, float(max_cost_usd_budget))
|
|
316
|
+
if budget_exceeded_action is not None:
|
|
317
|
+
box.budget_exceeded_action = budget_exceeded_action
|
|
318
|
+
manager.save_state()
|
|
319
|
+
else:
|
|
320
|
+
box = manager.create_sandbox(
|
|
321
|
+
dir_path=resolved_dir,
|
|
322
|
+
name=name,
|
|
323
|
+
base_image=p["image"],
|
|
324
|
+
is_persistent=p["persistent"],
|
|
325
|
+
network_policy=net_policy,
|
|
326
|
+
ports=p["ports"],
|
|
327
|
+
cpus=p["cpus"],
|
|
328
|
+
ram_mib=p["ram_mib"],
|
|
329
|
+
persist_docker=persist_docker,
|
|
330
|
+
protect_git_hooks=p["protect_git_hooks"],
|
|
331
|
+
protect_git_config=p["protect_git_config"],
|
|
332
|
+
protect_env_files=p["protect_env_files"],
|
|
333
|
+
host_egress_lockdown=p["host_egress_lockdown"],
|
|
334
|
+
idle_timeout_minutes=p["idle_timeout_minutes"],
|
|
335
|
+
idle_action=p["idle_action"],
|
|
336
|
+
env_vars=parsed_env,
|
|
337
|
+
secret_keys=list(parsed_secrets.keys()),
|
|
338
|
+
host_env_passthrough=pass_env,
|
|
339
|
+
max_tokens_budget=max_tokens_budget or 0,
|
|
340
|
+
max_cost_usd_budget=max_cost_usd_budget or 0.0,
|
|
341
|
+
budget_exceeded_action=budget_exceeded_action or "alert",
|
|
342
|
+
template_id=p.get("template_id"),
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
# Store any secrets into vault
|
|
346
|
+
for sk, sv in parsed_secrets.items():
|
|
347
|
+
manager.secrets.set_secret(box.name, sk, sv)
|
|
348
|
+
if sk not in box.secret_keys:
|
|
349
|
+
box.secret_keys.append(sk)
|
|
350
|
+
if parsed_secrets:
|
|
351
|
+
manager.save_state()
|
|
352
|
+
|
|
353
|
+
mode_label = "Persistent" if box.is_persistent else "Ephemeral"
|
|
354
|
+
tmpl_tag = f" [Template: {box.template_id}]" if box.template_id else ""
|
|
355
|
+
print(f"{BLUE}▶ Initializing {mode_label} MicroVM '{box.name}'{tmpl_tag} [Net: {box.network_policy.profile}]...{RESET}")
|
|
356
|
+
ok, msg = manager.start_sandbox(box.name, detach=detach, extra_args=extra_args)
|
|
357
|
+
if not ok:
|
|
358
|
+
print(f"{RED}Error:{RESET} {msg}", file=sys.stderr)
|
|
359
|
+
return 1
|
|
360
|
+
|
|
361
|
+
print(f"{GREEN}✓ {msg}{RESET}")
|
|
362
|
+
print(f" {BOLD}SSH Target:{RESET} ssh {box.name} (Port {box.ssh_port})")
|
|
363
|
+
for p_map in box.published_ports:
|
|
364
|
+
print(f" {BOLD}Host Forward:{RESET} http://127.0.0.1:{p_map.host_port} -> guest {p_map.guest_port}")
|
|
365
|
+
if box.protect_git_hooks:
|
|
366
|
+
print(f" {BOLD}Guardrail:{RESET} .git/hooks read-only protection active")
|
|
367
|
+
if box.host_egress_lockdown:
|
|
368
|
+
print(f" {BOLD}Guardrail:{RESET} Host-boundary egress lockdown enforced")
|
|
369
|
+
print()
|
|
370
|
+
return 0
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def cmd_create(
|
|
374
|
+
manager: SandboxManager,
|
|
375
|
+
dir_path: str,
|
|
376
|
+
name: Optional[str] = None,
|
|
377
|
+
template_id: Optional[str] = None,
|
|
378
|
+
image: Optional[str] = None,
|
|
379
|
+
persistent: Optional[bool] = None,
|
|
380
|
+
network_profile: Optional[str] = None,
|
|
381
|
+
allowed_domains: Optional[List[str]] = None,
|
|
382
|
+
denied_domains: Optional[List[str]] = None,
|
|
383
|
+
ports: Optional[List[str]] = None,
|
|
384
|
+
cpus: Optional[int] = None,
|
|
385
|
+
ram_mib: Optional[int] = None,
|
|
386
|
+
persist_docker: bool = False,
|
|
387
|
+
protect_git_hooks: Optional[bool] = None,
|
|
388
|
+
protect_git_config: Optional[bool] = None,
|
|
389
|
+
protect_env_files: Optional[bool] = None,
|
|
390
|
+
host_egress_lockdown: Optional[bool] = None,
|
|
391
|
+
idle_timeout_minutes: Optional[int] = None,
|
|
392
|
+
idle_action: Optional[str] = None,
|
|
393
|
+
env_pairs: Optional[List[str]] = None,
|
|
394
|
+
pass_env: Optional[List[str]] = None,
|
|
395
|
+
secret_pairs: Optional[List[str]] = None,
|
|
396
|
+
max_tokens_budget: Optional[int] = None,
|
|
397
|
+
max_cost_usd_budget: Optional[float] = None,
|
|
398
|
+
budget_exceeded_action: Optional[str] = None,
|
|
399
|
+
) -> int:
|
|
400
|
+
resolved_dir = str(Path(dir_path).resolve())
|
|
401
|
+
tmpl, p = _resolve_template_and_params(
|
|
402
|
+
manager=manager,
|
|
403
|
+
template_id=template_id,
|
|
404
|
+
image=image,
|
|
405
|
+
persistent=persistent,
|
|
406
|
+
network_profile=network_profile,
|
|
407
|
+
allowed_domains=allowed_domains,
|
|
408
|
+
denied_domains=denied_domains,
|
|
409
|
+
ports=ports,
|
|
410
|
+
cpus=cpus,
|
|
411
|
+
ram_mib=ram_mib,
|
|
412
|
+
protect_git_hooks=protect_git_hooks,
|
|
413
|
+
protect_git_config=protect_git_config,
|
|
414
|
+
protect_env_files=protect_env_files,
|
|
415
|
+
host_egress_lockdown=host_egress_lockdown,
|
|
416
|
+
idle_timeout_minutes=idle_timeout_minutes,
|
|
417
|
+
idle_action=idle_action,
|
|
418
|
+
)
|
|
419
|
+
if template_id and not tmpl:
|
|
420
|
+
return 1
|
|
421
|
+
|
|
422
|
+
net_policy = NetworkPolicy(
|
|
423
|
+
profile=p["network_profile"],
|
|
424
|
+
allowed_domains=p["allowed_domains"],
|
|
425
|
+
denied_domains=p["denied_domains"],
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
parsed_env = parse_kv_pairs(env_pairs)
|
|
429
|
+
parsed_secrets = parse_kv_pairs(secret_pairs)
|
|
430
|
+
|
|
431
|
+
box = manager.create_sandbox(
|
|
432
|
+
dir_path=resolved_dir,
|
|
433
|
+
name=name,
|
|
434
|
+
base_image=p["image"],
|
|
435
|
+
is_persistent=p["persistent"],
|
|
436
|
+
network_policy=net_policy,
|
|
437
|
+
ports=p["ports"],
|
|
438
|
+
cpus=p["cpus"],
|
|
439
|
+
ram_mib=p["ram_mib"],
|
|
440
|
+
persist_docker=persist_docker,
|
|
441
|
+
protect_git_hooks=p["protect_git_hooks"],
|
|
442
|
+
protect_git_config=p["protect_git_config"],
|
|
443
|
+
protect_env_files=p["protect_env_files"],
|
|
444
|
+
host_egress_lockdown=p["host_egress_lockdown"],
|
|
445
|
+
idle_timeout_minutes=p["idle_timeout_minutes"],
|
|
446
|
+
idle_action=p["idle_action"],
|
|
447
|
+
env_vars=parsed_env,
|
|
448
|
+
secret_keys=list(parsed_secrets.keys()),
|
|
449
|
+
host_env_passthrough=pass_env,
|
|
450
|
+
max_tokens_budget=max_tokens_budget or 0,
|
|
451
|
+
max_cost_usd_budget=max_cost_usd_budget or 0.0,
|
|
452
|
+
budget_exceeded_action=budget_exceeded_action or "alert",
|
|
453
|
+
template_id=p.get("template_id"),
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
for sk, sv in parsed_secrets.items():
|
|
457
|
+
manager.secrets.set_secret(box.name, sk, sv)
|
|
458
|
+
|
|
459
|
+
print(f"Registered sandbox '{box.name}' (Mode: {'Persistent' if box.is_persistent else 'Ephemeral'}, SSH: {box.ssh_port})")
|
|
460
|
+
return 0
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _merge_network_policy(
|
|
464
|
+
current: NetworkPolicy,
|
|
465
|
+
profile: Optional[str] = None,
|
|
466
|
+
allow: Optional[List[str]] = None,
|
|
467
|
+
remove_allow: Optional[List[str]] = None,
|
|
468
|
+
clear_allow: bool = False,
|
|
469
|
+
deny: Optional[List[str]] = None,
|
|
470
|
+
remove_deny: Optional[List[str]] = None,
|
|
471
|
+
clear_deny: bool = False,
|
|
472
|
+
) -> Optional[NetworkPolicy]:
|
|
473
|
+
has_net_args = any([
|
|
474
|
+
profile is not None,
|
|
475
|
+
bool(allow),
|
|
476
|
+
bool(remove_allow),
|
|
477
|
+
clear_allow,
|
|
478
|
+
bool(deny),
|
|
479
|
+
bool(remove_deny),
|
|
480
|
+
clear_deny,
|
|
481
|
+
])
|
|
482
|
+
if not has_net_args:
|
|
483
|
+
return None
|
|
484
|
+
|
|
485
|
+
new_profile = profile.lower() if profile else current.profile
|
|
486
|
+
new_allowed = [] if clear_allow else list(current.allowed_domains)
|
|
487
|
+
if remove_allow:
|
|
488
|
+
rem_set = {d.strip().lower().rstrip(".") for d in remove_allow if d and d.strip()}
|
|
489
|
+
new_allowed = [d for d in new_allowed if d.strip().lower().rstrip(".") not in rem_set]
|
|
490
|
+
if allow:
|
|
491
|
+
for d in allow:
|
|
492
|
+
clean = d.strip().lower().rstrip(".")
|
|
493
|
+
if clean and clean not in new_allowed:
|
|
494
|
+
new_allowed.append(clean)
|
|
495
|
+
|
|
496
|
+
new_denied = [] if clear_deny else list(current.denied_domains)
|
|
497
|
+
if remove_deny:
|
|
498
|
+
rem_d_set = {d.strip().lower().rstrip(".") for d in remove_deny if d and d.strip()}
|
|
499
|
+
new_denied = [d for d in new_denied if d.strip().lower().rstrip(".") not in rem_d_set]
|
|
500
|
+
if deny:
|
|
501
|
+
for d in deny:
|
|
502
|
+
clean = d.strip().lower().rstrip(".")
|
|
503
|
+
if clean and clean not in new_denied:
|
|
504
|
+
new_denied.append(clean)
|
|
505
|
+
|
|
506
|
+
return NetworkPolicy(
|
|
507
|
+
profile=new_profile,
|
|
508
|
+
allowed_domains=new_allowed,
|
|
509
|
+
denied_domains=new_denied,
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def cmd_edit(
|
|
514
|
+
manager: SandboxManager,
|
|
515
|
+
name: str,
|
|
516
|
+
new_name: Optional[str] = None,
|
|
517
|
+
cpus: Optional[int] = None,
|
|
518
|
+
ram_mib: Optional[int] = None,
|
|
519
|
+
network: Optional[str] = None,
|
|
520
|
+
allow: Optional[List[str]] = None,
|
|
521
|
+
remove_allow: Optional[List[str]] = None,
|
|
522
|
+
clear_allow: bool = False,
|
|
523
|
+
deny: Optional[List[str]] = None,
|
|
524
|
+
remove_deny: Optional[List[str]] = None,
|
|
525
|
+
clear_deny: bool = False,
|
|
526
|
+
protect_git_hooks: Optional[bool] = None,
|
|
527
|
+
protect_git_config: Optional[bool] = None,
|
|
528
|
+
protect_env_files: Optional[bool] = None,
|
|
529
|
+
host_egress_lockdown: Optional[bool] = None,
|
|
530
|
+
idle_timeout_minutes: Optional[int] = None,
|
|
531
|
+
idle_action: Optional[str] = None,
|
|
532
|
+
env_pairs: Optional[List[str]] = None,
|
|
533
|
+
unset_env: Optional[List[str]] = None,
|
|
534
|
+
pass_env: Optional[List[str]] = None,
|
|
535
|
+
unset_pass_env: Optional[List[str]] = None,
|
|
536
|
+
secret_pairs: Optional[List[str]] = None,
|
|
537
|
+
unset_secret: Optional[List[str]] = None,
|
|
538
|
+
max_tokens_budget: Optional[int] = None,
|
|
539
|
+
max_cost_usd_budget: Optional[float] = None,
|
|
540
|
+
budget_exceeded_action: Optional[str] = None,
|
|
541
|
+
) -> int:
|
|
542
|
+
box = manager.get_sandbox(name)
|
|
543
|
+
if not box:
|
|
544
|
+
print(f"{RED}Error: Sandbox '{name}' not found.{RESET}", file=sys.stderr)
|
|
545
|
+
return 1
|
|
546
|
+
|
|
547
|
+
target_policy = _merge_network_policy(
|
|
548
|
+
box.network_policy,
|
|
549
|
+
profile=network,
|
|
550
|
+
allow=allow,
|
|
551
|
+
remove_allow=remove_allow,
|
|
552
|
+
clear_allow=clear_allow,
|
|
553
|
+
deny=deny,
|
|
554
|
+
remove_deny=remove_deny,
|
|
555
|
+
clear_deny=clear_deny,
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
# Process env and secret additions / deletions
|
|
559
|
+
target_env = dict(box.env_vars)
|
|
560
|
+
if env_pairs:
|
|
561
|
+
target_env.update(parse_kv_pairs(env_pairs))
|
|
562
|
+
if unset_env:
|
|
563
|
+
for k in unset_env:
|
|
564
|
+
target_env.pop(k.strip(), None)
|
|
565
|
+
|
|
566
|
+
target_pass = list(box.host_env_passthrough)
|
|
567
|
+
if pass_env:
|
|
568
|
+
for pk in pass_env:
|
|
569
|
+
clean_pk = pk.strip()
|
|
570
|
+
if is_valid_env_key(clean_pk) and clean_pk not in target_pass:
|
|
571
|
+
target_pass.append(clean_pk)
|
|
572
|
+
if unset_pass_env:
|
|
573
|
+
target_pass = [p for p in target_pass if p not in unset_pass_env]
|
|
574
|
+
|
|
575
|
+
target_secret_keys = list(box.secret_keys)
|
|
576
|
+
secrets_updated = False
|
|
577
|
+
if secret_pairs:
|
|
578
|
+
parsed_sec = parse_kv_pairs(secret_pairs)
|
|
579
|
+
for sk, sv in parsed_sec.items():
|
|
580
|
+
manager.secrets.set_secret(box.name, sk, sv)
|
|
581
|
+
if sk not in target_secret_keys:
|
|
582
|
+
target_secret_keys.append(sk)
|
|
583
|
+
secrets_updated = True
|
|
584
|
+
if unset_secret:
|
|
585
|
+
for sk in unset_secret:
|
|
586
|
+
clean_sk = sk.strip()
|
|
587
|
+
manager.secrets.delete_secret(box.name, clean_sk)
|
|
588
|
+
if clean_sk in target_secret_keys:
|
|
589
|
+
target_secret_keys.remove(clean_sk)
|
|
590
|
+
secrets_updated = True
|
|
591
|
+
|
|
592
|
+
ok, msg, updated = manager.update_sandbox(
|
|
593
|
+
box.name,
|
|
594
|
+
new_name=new_name,
|
|
595
|
+
cpus=cpus,
|
|
596
|
+
ram_mib=ram_mib,
|
|
597
|
+
network_policy=target_policy,
|
|
598
|
+
protect_git_hooks=protect_git_hooks,
|
|
599
|
+
protect_git_config=protect_git_config,
|
|
600
|
+
protect_env_files=protect_env_files,
|
|
601
|
+
host_egress_lockdown=host_egress_lockdown,
|
|
602
|
+
idle_timeout_minutes=idle_timeout_minutes,
|
|
603
|
+
idle_action=idle_action,
|
|
604
|
+
env_vars=target_env if (env_pairs or unset_env) else None,
|
|
605
|
+
secret_keys=target_secret_keys if (secret_pairs or unset_secret) else None,
|
|
606
|
+
host_env_passthrough=target_pass if (pass_env or unset_pass_env) else None,
|
|
607
|
+
max_tokens_budget=max_tokens_budget,
|
|
608
|
+
max_cost_usd_budget=max_cost_usd_budget,
|
|
609
|
+
budget_exceeded_action=budget_exceeded_action,
|
|
610
|
+
secrets_updated=secrets_updated,
|
|
611
|
+
)
|
|
612
|
+
if not ok or updated is None:
|
|
613
|
+
print(f"{RED}Error:{RESET} {msg}", file=sys.stderr)
|
|
614
|
+
return 1
|
|
615
|
+
|
|
616
|
+
print(f"{GREEN}✓ {msg}{RESET}")
|
|
617
|
+
print(f" {BOLD}Sandbox:{RESET} {updated.name} ({updated.status.upper()})")
|
|
618
|
+
print(f" {BOLD}Hardware:{RESET} {updated.cpus} vCPUs / {updated.ram_mib} MiB RAM")
|
|
619
|
+
print(f" {BOLD}Network:{RESET} {updated.network_policy.profile} (+{len(updated.network_policy.allowed_domains)} custom allow, -{len(updated.network_policy.denied_domains)} deny)")
|
|
620
|
+
print(f" {BOLD}SSH Port:{RESET} {updated.ssh_port}")
|
|
621
|
+
if updated.idle_timeout_minutes > 0:
|
|
622
|
+
print(f" {BOLD}Idle Sleep:{RESET} {updated.idle_timeout_minutes}m ({updated.idle_action})")
|
|
623
|
+
print()
|
|
624
|
+
return 0
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def cmd_start(manager: SandboxManager, name: str, detach: bool = True) -> int:
|
|
628
|
+
box = manager.get_sandbox(name)
|
|
629
|
+
if not box:
|
|
630
|
+
print(f"{RED}Error: Sandbox '{name}' not found.{RESET}", file=sys.stderr)
|
|
631
|
+
return 1
|
|
632
|
+
ok, msg = manager.start_sandbox(box.name, detach=detach)
|
|
633
|
+
if not ok:
|
|
634
|
+
print(f"{RED}Error:{RESET} {msg}", file=sys.stderr)
|
|
635
|
+
return 1
|
|
636
|
+
print(f"{GREEN}✓ {msg}{RESET}")
|
|
637
|
+
return 0
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def cmd_stop(manager: SandboxManager, name: str) -> int:
|
|
641
|
+
ok, msg = manager.stop_sandbox(name)
|
|
642
|
+
if not ok:
|
|
643
|
+
print(f"{RED}Error:{RESET} {msg}", file=sys.stderr)
|
|
644
|
+
return 1
|
|
645
|
+
print(f"{GREEN}✓ {msg}{RESET}")
|
|
646
|
+
return 0
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def cmd_pause(manager: SandboxManager, name: str) -> int:
|
|
650
|
+
ok, msg = manager.pause_sandbox(name)
|
|
651
|
+
if not ok:
|
|
652
|
+
print(f"{RED}Error:{RESET} {msg}", file=sys.stderr)
|
|
653
|
+
return 1
|
|
654
|
+
print(f"{GREEN}✓ {msg}{RESET}")
|
|
655
|
+
return 0
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def cmd_unpause(manager: SandboxManager, name: str) -> int:
|
|
659
|
+
ok, msg = manager.unpause_sandbox(name)
|
|
660
|
+
if not ok:
|
|
661
|
+
print(f"{RED}Error:{RESET} {msg}", file=sys.stderr)
|
|
662
|
+
return 1
|
|
663
|
+
print(f"{GREEN}✓ {msg}{RESET}")
|
|
664
|
+
return 0
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def cmd_restart(manager: SandboxManager, name: str) -> int:
|
|
668
|
+
ok, msg = manager.restart_sandbox(name)
|
|
669
|
+
if not ok:
|
|
670
|
+
print(f"{RED}Error:{RESET} {msg}", file=sys.stderr)
|
|
671
|
+
return 1
|
|
672
|
+
print(f"{GREEN}✓ {msg}{RESET}")
|
|
673
|
+
return 0
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def cmd_recreate(
|
|
677
|
+
manager: SandboxManager,
|
|
678
|
+
name: str,
|
|
679
|
+
pull: bool = True,
|
|
680
|
+
wipe_volumes: bool = True,
|
|
681
|
+
yes: bool = False,
|
|
682
|
+
) -> int:
|
|
683
|
+
"""Recreate a sandbox (wipe container/volumes and rebuild with same configuration)."""
|
|
684
|
+
if not yes:
|
|
685
|
+
try:
|
|
686
|
+
ans = input(
|
|
687
|
+
f"Are you sure you want to recreate sandbox '{name}'? "
|
|
688
|
+
f"This wipes all container data and rebuilds it with the same settings. [y/N]: "
|
|
689
|
+
)
|
|
690
|
+
except (EOFError, KeyboardInterrupt):
|
|
691
|
+
print("\nRecreation cancelled.")
|
|
692
|
+
return 0
|
|
693
|
+
if ans.strip().lower() not in ("y", "yes"):
|
|
694
|
+
print("Recreation cancelled.")
|
|
695
|
+
return 0
|
|
696
|
+
|
|
697
|
+
ok, msg = manager.recreate_sandbox(
|
|
698
|
+
name,
|
|
699
|
+
pull_image=pull,
|
|
700
|
+
wipe_volumes=wipe_volumes,
|
|
701
|
+
start_after=True,
|
|
702
|
+
)
|
|
703
|
+
if not ok:
|
|
704
|
+
print(f"{RED}Error:{RESET} {msg}", file=sys.stderr)
|
|
705
|
+
return 1
|
|
706
|
+
print(f"{GREEN}✓ {msg}{RESET}")
|
|
707
|
+
return 0
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def cmd_rm(manager: SandboxManager, name: str, volumes: bool = False) -> int:
|
|
711
|
+
ok, msg = manager.delete_sandbox(name, delete_volumes=volumes)
|
|
712
|
+
if not ok:
|
|
713
|
+
print(f"{RED}Error:{RESET} {msg}", file=sys.stderr)
|
|
714
|
+
return 1
|
|
715
|
+
print(f"{GREEN}✓ {msg}{RESET}")
|
|
716
|
+
return 0
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def cmd_ssh(manager: SandboxManager, name_or_dir: Optional[str] = None, open_terminal: bool = False) -> int:
|
|
720
|
+
if name_or_dir:
|
|
721
|
+
box = manager.get_sandbox(name_or_dir) or manager.get_sandbox_for_dir(name_or_dir)
|
|
722
|
+
else:
|
|
723
|
+
box = manager.get_sandbox_for_dir(os.getcwd())
|
|
724
|
+
|
|
725
|
+
if not box:
|
|
726
|
+
print(f"{RED}Error: No sandbox found for target.{RESET}", file=sys.stderr)
|
|
727
|
+
return 1
|
|
728
|
+
|
|
729
|
+
if box.status != "running":
|
|
730
|
+
print(f"{YELLOW}Starting stopped sandbox '{box.name}'...{RESET}")
|
|
731
|
+
ok, msg = manager.start_sandbox(box.name, detach=True)
|
|
732
|
+
if not ok:
|
|
733
|
+
print(f"{RED}Error starting sandbox:{RESET} {msg}", file=sys.stderr)
|
|
734
|
+
return 1
|
|
735
|
+
|
|
736
|
+
if open_terminal:
|
|
737
|
+
from ..core.ssh import launch_ssh_terminal, wait_for_ssh_ready, is_ssh_ready
|
|
738
|
+
if not is_ssh_ready("127.0.0.1", box.ssh_port, timeout=0.2):
|
|
739
|
+
wait_for_ssh_ready("127.0.0.1", box.ssh_port, max_wait=3.0, poll_interval=0.25)
|
|
740
|
+
ok, msg = launch_ssh_terminal(box.name)
|
|
741
|
+
if ok:
|
|
742
|
+
print(f"{GREEN}✓ {msg}{RESET}")
|
|
743
|
+
return 0
|
|
744
|
+
else:
|
|
745
|
+
print(f"{YELLOW}Notice: {msg}{RESET}")
|
|
746
|
+
return subprocess.run(["ssh", box.name]).returncode
|
|
747
|
+
|
|
748
|
+
from ..core.ssh import wait_for_ssh_ready, is_ssh_ready
|
|
749
|
+
if not is_ssh_ready("127.0.0.1", box.ssh_port, timeout=0.2):
|
|
750
|
+
wait_for_ssh_ready("127.0.0.1", box.ssh_port, max_wait=3.0, poll_interval=0.25)
|
|
751
|
+
return subprocess.run(["ssh", box.name]).returncode
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def cmd_code(manager: SandboxManager, name: str, ide_key: str = "vscode", remote_path: str = "/workspace") -> int:
|
|
755
|
+
box = manager.get_sandbox(name)
|
|
756
|
+
if not box:
|
|
757
|
+
print(f"{RED}Error: Sandbox '{name}' not found.{RESET}", file=sys.stderr)
|
|
758
|
+
return 1
|
|
759
|
+
|
|
760
|
+
if box.status != "running":
|
|
761
|
+
print(f"{YELLOW}Resuming sandbox '{box.name}'...{RESET}")
|
|
762
|
+
ok, msg = manager.start_sandbox(box.name, detach=True)
|
|
763
|
+
if not ok:
|
|
764
|
+
print(f"{RED}Error starting sandbox:{RESET} {msg}", file=sys.stderr)
|
|
765
|
+
return 1
|
|
766
|
+
|
|
767
|
+
from ..core.ide import launch_ide
|
|
768
|
+
ok, msg = launch_ide(box.name, ide_key=ide_key, remote_path=remote_path, ssh_port=box.ssh_port)
|
|
769
|
+
if ok:
|
|
770
|
+
print(f"{GREEN}✓ {msg}{RESET}")
|
|
771
|
+
return 0
|
|
772
|
+
else:
|
|
773
|
+
print(f"{RED}Error:{RESET} {msg}", file=sys.stderr)
|
|
774
|
+
return 1
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def cmd_exec(manager: SandboxManager, name: str, command: List[str], notify: bool = False) -> int:
|
|
778
|
+
box = manager.get_sandbox(name)
|
|
779
|
+
if not box:
|
|
780
|
+
print(f"{RED}Error: Sandbox '{name}' not found.{RESET}", file=sys.stderr)
|
|
781
|
+
return 1
|
|
782
|
+
|
|
783
|
+
if box.status != "running":
|
|
784
|
+
print(f"{RED}Error: Sandbox '{box.name}' is not running.{RESET}", file=sys.stderr)
|
|
785
|
+
return 1
|
|
786
|
+
|
|
787
|
+
from ..core.ssh import wait_for_ssh_ready, is_ssh_ready
|
|
788
|
+
if not is_ssh_ready("127.0.0.1", box.ssh_port, timeout=0.2):
|
|
789
|
+
wait_for_ssh_ready("127.0.0.1", box.ssh_port, max_wait=3.0, poll_interval=0.25)
|
|
790
|
+
cmd = ["ssh", box.name, "--"] + command
|
|
791
|
+
if notify:
|
|
792
|
+
def runner() -> int:
|
|
793
|
+
return subprocess.run(cmd).returncode
|
|
794
|
+
return run_with_completion_notification(box.name, " ".join(command), runner)
|
|
795
|
+
|
|
796
|
+
return subprocess.run(cmd).returncode
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def cmd_diff(manager: SandboxManager, name: str, rel_file: Optional[str] = None, stat_only: bool = False, json_output: bool = False) -> int:
|
|
800
|
+
box = manager.get_sandbox(name)
|
|
801
|
+
if not box:
|
|
802
|
+
if json_output:
|
|
803
|
+
import json
|
|
804
|
+
print(json.dumps({"error": f"Sandbox '{name}' not found"}, indent=2))
|
|
805
|
+
return 1
|
|
806
|
+
print(f"{RED}Error: Sandbox '{name}' not found.{RESET}", file=sys.stderr)
|
|
807
|
+
return 1
|
|
808
|
+
|
|
809
|
+
changes = manager.snapshots.list_changes(box.name, box.dir_path)
|
|
810
|
+
if json_output:
|
|
811
|
+
import json
|
|
812
|
+
payload = {
|
|
813
|
+
"sandbox": box.name,
|
|
814
|
+
"changes": [c.to_dict() for c in changes],
|
|
815
|
+
}
|
|
816
|
+
if rel_file:
|
|
817
|
+
payload["diff"] = manager.snapshots.get_file_diff(box.name, box.dir_path, rel_file)
|
|
818
|
+
print(json.dumps(payload, indent=2))
|
|
819
|
+
return 0
|
|
820
|
+
|
|
821
|
+
if not changes:
|
|
822
|
+
console.print("[green]✓ No modifications detected. Workspace matches pre-run baseline.[/green]")
|
|
823
|
+
return 0
|
|
824
|
+
|
|
825
|
+
console.print(f"\n[bold]Workspace Changes for '{box.name}':[/bold]")
|
|
826
|
+
table = Table(box=rich_box.ROUNDED, header_style="bold cyan")
|
|
827
|
+
table.add_column("STATUS", justify="center", no_wrap=True)
|
|
828
|
+
table.add_column("FILE", style="bold white")
|
|
829
|
+
table.add_column("DIFF STATS", justify="right")
|
|
830
|
+
|
|
831
|
+
for ch in changes:
|
|
832
|
+
status_disp = {
|
|
833
|
+
"modified": "[yellow]M[/yellow]",
|
|
834
|
+
"added": "[green]A[/green]",
|
|
835
|
+
"deleted": "[red]D[/red]",
|
|
836
|
+
"renamed": "[cyan]R[/cyan]",
|
|
837
|
+
}.get(ch.status, ch.status)
|
|
838
|
+
diff_stats = f"[green]+{ch.additions}[/green] / [red]-{ch.deletions}[/red]"
|
|
839
|
+
table.add_row(f"[{status_disp}]", ch.path, diff_stats)
|
|
840
|
+
|
|
841
|
+
console.print(table)
|
|
842
|
+
|
|
843
|
+
if stat_only:
|
|
844
|
+
console.print()
|
|
845
|
+
return 0
|
|
846
|
+
|
|
847
|
+
if rel_file:
|
|
848
|
+
diff_text = manager.snapshots.get_file_diff(box.name, box.dir_path, rel_file)
|
|
849
|
+
if diff_text:
|
|
850
|
+
console.print(f"\n[bold]Diff for {rel_file}:[/bold]\n{diff_text}")
|
|
851
|
+
else:
|
|
852
|
+
console.print(f"\n[dim]No diff for {rel_file}.[/dim]")
|
|
853
|
+
else:
|
|
854
|
+
console.print(f"\n[dim]Use `krunbox diff {box.name} <file>` to inspect unified diff for a single file.[/dim]\n")
|
|
855
|
+
return 0
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
def cmd_rollback(manager: SandboxManager, name: str, rel_file: Optional[str] = None, accept: bool = False) -> int:
|
|
859
|
+
box = manager.get_sandbox(name)
|
|
860
|
+
if not box:
|
|
861
|
+
print(f"{RED}Error: Sandbox '{name}' not found.{RESET}", file=sys.stderr)
|
|
862
|
+
return 1
|
|
863
|
+
|
|
864
|
+
if accept:
|
|
865
|
+
ok, msg = manager.snapshots.accept_changes(box.name, box.dir_path)
|
|
866
|
+
if ok:
|
|
867
|
+
print(f"{GREEN}✓ {msg}{RESET}")
|
|
868
|
+
return 0
|
|
869
|
+
print(f"{RED}Error:{RESET} {msg}", file=sys.stderr)
|
|
870
|
+
return 1
|
|
871
|
+
|
|
872
|
+
if rel_file:
|
|
873
|
+
ok, msg = manager.snapshots.revert_file(box.name, box.dir_path, rel_file)
|
|
874
|
+
if ok:
|
|
875
|
+
print(f"{GREEN}✓ {msg}{RESET}")
|
|
876
|
+
return 0
|
|
877
|
+
print(f"{RED}Error:{RESET} {msg}", file=sys.stderr)
|
|
878
|
+
return 1
|
|
879
|
+
|
|
880
|
+
ok, msg = manager.snapshots.rollback_all(box.name, box.dir_path)
|
|
881
|
+
if ok:
|
|
882
|
+
print(f"{GREEN}✓ {msg}{RESET}")
|
|
883
|
+
return 0
|
|
884
|
+
print(f"{RED}Error:{RESET} {msg}", file=sys.stderr)
|
|
885
|
+
return 1
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
def cmd_snapshot(manager: SandboxManager, name: str) -> int:
|
|
889
|
+
box = manager.get_sandbox(name)
|
|
890
|
+
if not box:
|
|
891
|
+
print(f"{RED}Error: Sandbox '{name}' not found.{RESET}", file=sys.stderr)
|
|
892
|
+
return 1
|
|
893
|
+
ok, msg, _ = manager.snapshots.create_snapshot(box.name, box.dir_path)
|
|
894
|
+
if ok:
|
|
895
|
+
print(f"{GREEN}✓ {msg}{RESET}")
|
|
896
|
+
return 0
|
|
897
|
+
print(f"{RED}Error:{RESET} {msg}", file=sys.stderr)
|
|
898
|
+
return 1
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
def cmd_templates(
|
|
902
|
+
manager: SandboxManager,
|
|
903
|
+
action: str = "list",
|
|
904
|
+
target: Optional[str] = None,
|
|
905
|
+
from_sandbox: Optional[str] = None,
|
|
906
|
+
description: str = "",
|
|
907
|
+
) -> int:
|
|
908
|
+
clean_action = (action or "list").lower()
|
|
909
|
+
if clean_action in ("list", "ls"):
|
|
910
|
+
tmpls = manager.templates.list_templates()
|
|
911
|
+
table = Table(box=rich_box.ROUNDED, header_style="bold cyan")
|
|
912
|
+
table.add_column("ID", style="bold white", no_wrap=True)
|
|
913
|
+
table.add_column("NAME")
|
|
914
|
+
table.add_column("TYPE", no_wrap=True)
|
|
915
|
+
table.add_column("CPUS/RAM", justify="right", no_wrap=True)
|
|
916
|
+
table.add_column("NETWORK", no_wrap=True)
|
|
917
|
+
table.add_column("PORTS")
|
|
918
|
+
for t in tmpls:
|
|
919
|
+
type_tag = "[blue]BUILTIN[/blue]" if t.builtin else "[cyan]CUSTOM[/cyan]"
|
|
920
|
+
hw = f"{t.cpus}c / {t.ram_mib}m"
|
|
921
|
+
ports_str = ", ".join(t.ports) if t.ports else "-"
|
|
922
|
+
table.add_row(t.id, t.name, type_tag, hw, t.network_profile, ports_str)
|
|
923
|
+
console.print()
|
|
924
|
+
console.print(table)
|
|
925
|
+
console.print(f"\n[dim]Use `krunbox run --template <id>` to launch a sandbox from a preset.[/dim]\n")
|
|
926
|
+
return 0
|
|
927
|
+
|
|
928
|
+
if clean_action == "save":
|
|
929
|
+
if not target:
|
|
930
|
+
print(f"{RED}Error: Must provide template ID to save.{RESET}", file=sys.stderr)
|
|
931
|
+
return 1
|
|
932
|
+
if not from_sandbox:
|
|
933
|
+
print(f"{RED}Error: Must provide --from-sandbox <name>.{RESET}", file=sys.stderr)
|
|
934
|
+
return 1
|
|
935
|
+
box = manager.get_sandbox(from_sandbox)
|
|
936
|
+
if not box:
|
|
937
|
+
print(f"{RED}Error: Source sandbox '{from_sandbox}' not found.{RESET}", file=sys.stderr)
|
|
938
|
+
return 1
|
|
939
|
+
|
|
940
|
+
tmpl = SandboxTemplate(
|
|
941
|
+
id=target,
|
|
942
|
+
name=target,
|
|
943
|
+
description=description or f"Preset saved from {box.name}",
|
|
944
|
+
builtin=False,
|
|
945
|
+
base_image=box.base_image,
|
|
946
|
+
cpus=box.cpus,
|
|
947
|
+
ram_mib=box.ram_mib,
|
|
948
|
+
is_persistent=box.is_persistent,
|
|
949
|
+
network_profile=box.network_policy.profile,
|
|
950
|
+
allowed_domains=list(box.network_policy.allowed_domains),
|
|
951
|
+
denied_domains=list(box.network_policy.denied_domains),
|
|
952
|
+
ports=[str(p.guest_port) for p in box.published_ports],
|
|
953
|
+
protect_git_hooks=box.protect_git_hooks,
|
|
954
|
+
protect_git_config=box.protect_git_config,
|
|
955
|
+
protect_env_files=box.protect_env_files,
|
|
956
|
+
host_egress_lockdown=box.host_egress_lockdown,
|
|
957
|
+
idle_timeout_minutes=box.idle_timeout_minutes,
|
|
958
|
+
idle_action=box.idle_action,
|
|
959
|
+
)
|
|
960
|
+
saved = manager.templates.save_custom_template(tmpl)
|
|
961
|
+
print(f"{GREEN}✓ Saved custom template '{saved.id}'.{RESET}")
|
|
962
|
+
return 0
|
|
963
|
+
|
|
964
|
+
if clean_action in ("delete", "rm"):
|
|
965
|
+
if not target:
|
|
966
|
+
print(f"{RED}Error: Must provide template ID to delete.{RESET}", file=sys.stderr)
|
|
967
|
+
return 1
|
|
968
|
+
if manager.templates.delete_custom_template(target):
|
|
969
|
+
print(f"{GREEN}✓ Deleted custom template '{target}'.{RESET}")
|
|
970
|
+
return 0
|
|
971
|
+
print(f"{RED}Error: Could not delete template '{target}' (built-in templates cannot be deleted).{RESET}", file=sys.stderr)
|
|
972
|
+
return 1
|
|
973
|
+
|
|
974
|
+
return 0
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
def cmd_secret(
|
|
978
|
+
manager: SandboxManager,
|
|
979
|
+
action: str,
|
|
980
|
+
name: str,
|
|
981
|
+
key: Optional[str] = None,
|
|
982
|
+
value: Optional[str] = None,
|
|
983
|
+
) -> int:
|
|
984
|
+
box = manager.get_sandbox(name)
|
|
985
|
+
if not box:
|
|
986
|
+
print(f"{RED}Error: Sandbox '{name}' not found.{RESET}", file=sys.stderr)
|
|
987
|
+
return 1
|
|
988
|
+
|
|
989
|
+
clean_act = action.lower()
|
|
990
|
+
if clean_act in ("list", "ls"):
|
|
991
|
+
keys = manager.secrets.list_secret_keys(box.name, known_keys=box.secret_keys)
|
|
992
|
+
console.print(f"\n[bold]Scoped Secret Vault for '{box.name}':[/bold]")
|
|
993
|
+
if not keys:
|
|
994
|
+
console.print(" [dim]No scoped secrets stored in vault.[/dim]\n")
|
|
995
|
+
return 0
|
|
996
|
+
table = Table(box=rich_box.ROUNDED, header_style="bold cyan")
|
|
997
|
+
table.add_column("SECRET KEY", style="bold white")
|
|
998
|
+
table.add_column("VALUE / MASK", style="dim")
|
|
999
|
+
for k in keys:
|
|
1000
|
+
stored_val = manager.secrets.get_secret(box.name, k)
|
|
1001
|
+
masked = f"{stored_val[:3]}... ({len(stored_val)} chars)" if stored_val and len(stored_val) > 4 else "(masked)"
|
|
1002
|
+
table.add_row(k, masked)
|
|
1003
|
+
console.print(table)
|
|
1004
|
+
console.print()
|
|
1005
|
+
return 0
|
|
1006
|
+
|
|
1007
|
+
if clean_act == "set":
|
|
1008
|
+
if not key or not is_valid_env_key(key):
|
|
1009
|
+
print(f"{RED}Error: Invalid secret key name '{key}'. Must be alphanumeric identifier.{RESET}", file=sys.stderr)
|
|
1010
|
+
return 1
|
|
1011
|
+
val_to_store = value
|
|
1012
|
+
if val_to_store is None:
|
|
1013
|
+
if not sys.stdin.isatty():
|
|
1014
|
+
val_to_store = sys.stdin.read().rstrip("\r\n")
|
|
1015
|
+
else:
|
|
1016
|
+
import getpass
|
|
1017
|
+
val_to_store = getpass.getpass(f"Enter secret value for '{key}': ")
|
|
1018
|
+
backend = manager.secrets.set_secret(box.name, key, val_to_store)
|
|
1019
|
+
if key not in box.secret_keys:
|
|
1020
|
+
box.secret_keys.append(key)
|
|
1021
|
+
manager.save_state()
|
|
1022
|
+
print(f"{GREEN}✓ Stored '{key}' into {backend} for sandbox '{box.name}'.{RESET}")
|
|
1023
|
+
return 0
|
|
1024
|
+
|
|
1025
|
+
if clean_act in ("rm", "delete", "unset"):
|
|
1026
|
+
if not key:
|
|
1027
|
+
print(f"{RED}Error: Must provide secret key name to delete.{RESET}", file=sys.stderr)
|
|
1028
|
+
return 1
|
|
1029
|
+
manager.secrets.delete_secret(box.name, key)
|
|
1030
|
+
if key in box.secret_keys:
|
|
1031
|
+
box.secret_keys.remove(key)
|
|
1032
|
+
manager.save_state()
|
|
1033
|
+
print(f"{GREEN}✓ Removed secret '{key}' from vault for '{box.name}'.{RESET}")
|
|
1034
|
+
return 0
|
|
1035
|
+
|
|
1036
|
+
return 0
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
def cmd_idle_check(manager: SandboxManager, notify: bool = False) -> int:
|
|
1040
|
+
alerts, executed = manager.evaluate_guardrails()
|
|
1041
|
+
print(f"\n{BOLD}krunbox Guardrail & Idle Check Evaluation:{RESET}")
|
|
1042
|
+
print("─" * 60)
|
|
1043
|
+
if not alerts and not executed:
|
|
1044
|
+
print(f" {GREEN}✓ All guardrails within nominal operating thresholds. No actions taken.{RESET}\n")
|
|
1045
|
+
return 0
|
|
1046
|
+
|
|
1047
|
+
for alert in alerts:
|
|
1048
|
+
sev_color = RED if alert.severity == "critical" else YELLOW
|
|
1049
|
+
print(f" [{sev_color}{alert.severity.upper()}{RESET}] {alert.title}: {alert.body}")
|
|
1050
|
+
if notify:
|
|
1051
|
+
send_desktop_notification(alert.title, alert.body, urgent=(alert.severity == "critical"))
|
|
1052
|
+
|
|
1053
|
+
for box_name, action_verb, reason in executed:
|
|
1054
|
+
print(f" {CYAN}Action Executed:{RESET} {action_verb.upper()} '{box_name}' ({reason})")
|
|
1055
|
+
|
|
1056
|
+
print()
|
|
1057
|
+
return 0
|
|
1058
|
+
|
|
1059
|
+
|
|
1060
|
+
def cmd_forward(manager: SandboxManager, name: str, spec: str) -> int:
|
|
1061
|
+
box = manager.get_sandbox(name)
|
|
1062
|
+
if not box:
|
|
1063
|
+
print(f"{RED}Error: Sandbox '{name}' not found.{RESET}", file=sys.stderr)
|
|
1064
|
+
return 1
|
|
1065
|
+
|
|
1066
|
+
try:
|
|
1067
|
+
parts = spec.split(":")
|
|
1068
|
+
if len(parts) == 1:
|
|
1069
|
+
guest_port = int(parts[0])
|
|
1070
|
+
host_port = None
|
|
1071
|
+
elif len(parts) == 2:
|
|
1072
|
+
host_port = int(parts[0])
|
|
1073
|
+
guest_port = int(parts[1])
|
|
1074
|
+
else:
|
|
1075
|
+
print(f"{RED}Invalid port format. Use GUEST or HOST:GUEST.{RESET}", file=sys.stderr)
|
|
1076
|
+
return 1
|
|
1077
|
+
except ValueError:
|
|
1078
|
+
print(f"{RED}Invalid port format. Port numbers must be valid integers.{RESET}", file=sys.stderr)
|
|
1079
|
+
return 1
|
|
1080
|
+
|
|
1081
|
+
ok, msg, mapping = manager.forward_port(box.name, guest_port=guest_port, host_port=host_port)
|
|
1082
|
+
if not ok:
|
|
1083
|
+
print(f"{RED}Error:{RESET} {msg}", file=sys.stderr)
|
|
1084
|
+
return 1
|
|
1085
|
+
|
|
1086
|
+
print(f"{GREEN}✓ {msg}{RESET}")
|
|
1087
|
+
print(f" {CYAN}Live URL:{RESET} http://127.0.0.1:{mapping.host_port}")
|
|
1088
|
+
return 0
|
|
1089
|
+
|
|
1090
|
+
|
|
1091
|
+
def cmd_unforward(manager: SandboxManager, name: str, guest_port: int) -> int:
|
|
1092
|
+
ok, msg = manager.unforward_port(name, guest_port=guest_port)
|
|
1093
|
+
if not ok:
|
|
1094
|
+
print(f"{RED}Error:{RESET} {msg}", file=sys.stderr)
|
|
1095
|
+
return 1
|
|
1096
|
+
print(f"{GREEN}✓ {msg}{RESET}")
|
|
1097
|
+
return 0
|
|
1098
|
+
|
|
1099
|
+
|
|
1100
|
+
def cmd_ports(manager: SandboxManager, name: str, json_output: bool = False) -> int:
|
|
1101
|
+
box = manager.get_sandbox(name)
|
|
1102
|
+
if not box:
|
|
1103
|
+
if json_output:
|
|
1104
|
+
import json
|
|
1105
|
+
print(json.dumps({"error": f"Sandbox '{name}' not found"}, indent=2))
|
|
1106
|
+
return 1
|
|
1107
|
+
print(f"{RED}Error: Sandbox '{name}' not found.{RESET}", file=sys.stderr)
|
|
1108
|
+
return 1
|
|
1109
|
+
|
|
1110
|
+
if json_output:
|
|
1111
|
+
import json
|
|
1112
|
+
payload = {
|
|
1113
|
+
"sandbox": box.name,
|
|
1114
|
+
"ssh_port": box.ssh_port,
|
|
1115
|
+
"published_ports": [p.to_dict() for p in box.published_ports],
|
|
1116
|
+
"dynamic_tunnels": [p.to_dict() for p in box.dynamic_tunnels],
|
|
1117
|
+
}
|
|
1118
|
+
print(json.dumps(payload, indent=2))
|
|
1119
|
+
return 0
|
|
1120
|
+
|
|
1121
|
+
console.print(f"\n[bold]Port Configurations for {box.name}:[/bold]")
|
|
1122
|
+
console.print(f" SSH Management: 127.0.0.1:{box.ssh_port} -> guest 2222")
|
|
1123
|
+
|
|
1124
|
+
if box.published_ports or box.dynamic_tunnels:
|
|
1125
|
+
table = Table(box=rich_box.ROUNDED, header_style="bold cyan")
|
|
1126
|
+
table.add_column("TYPE", style="bold white")
|
|
1127
|
+
table.add_column("HOST PORT", justify="right")
|
|
1128
|
+
table.add_column("GUEST PORT", justify="right")
|
|
1129
|
+
table.add_column("DETAILS")
|
|
1130
|
+
for p in box.published_ports:
|
|
1131
|
+
table.add_row("Static (Podman)", str(p.host_port), str(p.guest_port), f"http://127.0.0.1:{p.host_port} -> guest {p.guest_port}")
|
|
1132
|
+
for p in box.dynamic_tunnels:
|
|
1133
|
+
pid_str = f" [PID {p.tunnel_pid}]" if p.tunnel_pid else ""
|
|
1134
|
+
table.add_row("Dynamic Tunnel", str(p.host_port), str(p.guest_port), f"http://127.0.0.1:{p.host_port} -> guest {p.guest_port}{pid_str}")
|
|
1135
|
+
console.print()
|
|
1136
|
+
console.print(table)
|
|
1137
|
+
else:
|
|
1138
|
+
console.print(f" [dim]No application ports mapped. Use `krunbox forward {box.name} <port>` to expose one.[/dim]")
|
|
1139
|
+
console.print()
|
|
1140
|
+
return 0
|
|
1141
|
+
|
|
1142
|
+
|
|
1143
|
+
def cmd_network(
|
|
1144
|
+
manager: SandboxManager,
|
|
1145
|
+
name: str,
|
|
1146
|
+
allow: Optional[List[str]] = None,
|
|
1147
|
+
deny: Optional[List[str]] = None,
|
|
1148
|
+
profile: Optional[str] = None,
|
|
1149
|
+
remove_allow: Optional[List[str]] = None,
|
|
1150
|
+
remove_deny: Optional[List[str]] = None,
|
|
1151
|
+
show_traffic: bool = False,
|
|
1152
|
+
) -> int:
|
|
1153
|
+
box = manager.get_sandbox(name)
|
|
1154
|
+
if not box:
|
|
1155
|
+
print(f"{RED}Error: Sandbox '{name}' not found.{RESET}", file=sys.stderr)
|
|
1156
|
+
return 1
|
|
1157
|
+
|
|
1158
|
+
target_policy = _merge_network_policy(
|
|
1159
|
+
box.network_policy,
|
|
1160
|
+
profile=profile,
|
|
1161
|
+
allow=allow,
|
|
1162
|
+
remove_allow=remove_allow,
|
|
1163
|
+
deny=deny,
|
|
1164
|
+
remove_deny=remove_deny,
|
|
1165
|
+
)
|
|
1166
|
+
if target_policy is not None:
|
|
1167
|
+
ok, msg, updated = manager.update_sandbox(box.name, network_policy=target_policy)
|
|
1168
|
+
if not ok or updated is None:
|
|
1169
|
+
print(f"{RED}Error:{RESET} {msg}", file=sys.stderr)
|
|
1170
|
+
return 1
|
|
1171
|
+
box = updated
|
|
1172
|
+
print(f"{GREEN}✓ Updated network egress policy for '{box.name}'.{RESET}")
|
|
1173
|
+
|
|
1174
|
+
from ..core.network import get_profile_domains
|
|
1175
|
+
print(f"\n{BOLD}Network Egress Policy: {box.name}{RESET}")
|
|
1176
|
+
print("─" * 60)
|
|
1177
|
+
print(f"Profile: {BOLD}{box.network_policy.profile.upper()}{RESET}")
|
|
1178
|
+
|
|
1179
|
+
target_allowed = get_profile_domains(box.network_policy.profile, box.network_policy.allowed_domains)
|
|
1180
|
+
print(f"\n{GREEN}Whitelisted Domains ({len(target_allowed)} total):{RESET}")
|
|
1181
|
+
if target_allowed:
|
|
1182
|
+
for d in sorted(target_allowed):
|
|
1183
|
+
custom_tag = f" {CYAN}(custom allow){RESET}" if d in box.network_policy.allowed_domains else ""
|
|
1184
|
+
print(f" • {d}{custom_tag}")
|
|
1185
|
+
else:
|
|
1186
|
+
print(" (all outbound traffic permitted if 'full' or none if 'offline')")
|
|
1187
|
+
|
|
1188
|
+
print(f"\n{RED}Blacklisted / Denied Domains ({len(box.network_policy.denied_domains)} total):{RESET}")
|
|
1189
|
+
if box.network_policy.denied_domains:
|
|
1190
|
+
for d in sorted(box.network_policy.denied_domains):
|
|
1191
|
+
print(f" • {d} {RED}[BLOCKED]{RESET}")
|
|
1192
|
+
else:
|
|
1193
|
+
print(" (no domains blacklisted)")
|
|
1194
|
+
print()
|
|
1195
|
+
|
|
1196
|
+
if show_traffic:
|
|
1197
|
+
return cmd_traffic(manager, name=box.name)
|
|
1198
|
+
return 0
|
|
1199
|
+
|
|
1200
|
+
|
|
1201
|
+
def cmd_traffic(
|
|
1202
|
+
manager: SandboxManager,
|
|
1203
|
+
name: Optional[str] = None,
|
|
1204
|
+
limit: int = 20,
|
|
1205
|
+
clear: bool = False,
|
|
1206
|
+
json_output: bool = False,
|
|
1207
|
+
export_path: Optional[str] = None,
|
|
1208
|
+
) -> int:
|
|
1209
|
+
"""Display corporate outbound domain egress traffic analytics and blocked call audit."""
|
|
1210
|
+
target_name: Optional[str] = None
|
|
1211
|
+
if name:
|
|
1212
|
+
box = manager.get_sandbox(name)
|
|
1213
|
+
target_name = box.name if box else name
|
|
1214
|
+
|
|
1215
|
+
if clear:
|
|
1216
|
+
manager.egress_traffic.clear_logs(sandbox_name=target_name)
|
|
1217
|
+
scope = f"for '{target_name}'" if target_name else "across all sandboxes"
|
|
1218
|
+
if json_output:
|
|
1219
|
+
import json
|
|
1220
|
+
print(json.dumps({"status": "cleared", "scope": target_name}, indent=2))
|
|
1221
|
+
return 0
|
|
1222
|
+
print(f"{GREEN}✓ Cleared egress traffic logs {scope}.{RESET}")
|
|
1223
|
+
return 0
|
|
1224
|
+
|
|
1225
|
+
if export_path:
|
|
1226
|
+
ok, msg = manager.egress_traffic.export_domain_stats(export_path, sandbox_name=target_name)
|
|
1227
|
+
if ok:
|
|
1228
|
+
if json_output:
|
|
1229
|
+
import json
|
|
1230
|
+
print(json.dumps({"status": "ok", "message": msg, "path": str(export_path)}, indent=2))
|
|
1231
|
+
else:
|
|
1232
|
+
print(f"\n{GREEN}✓ {msg}{RESET}\n")
|
|
1233
|
+
return 0
|
|
1234
|
+
else:
|
|
1235
|
+
if json_output:
|
|
1236
|
+
import json
|
|
1237
|
+
print(json.dumps({"status": "error", "message": msg, "path": str(export_path)}, indent=2), file=sys.stderr)
|
|
1238
|
+
else:
|
|
1239
|
+
print(f"\n{RED}✗ Export failed: {msg}{RESET}\n", file=sys.stderr)
|
|
1240
|
+
return 1
|
|
1241
|
+
|
|
1242
|
+
stats = manager.egress_traffic.get_domain_stats(sandbox_name=target_name)
|
|
1243
|
+
recent = manager.egress_traffic.get_events(sandbox_name=target_name, limit=max(1, limit))
|
|
1244
|
+
|
|
1245
|
+
if json_output:
|
|
1246
|
+
import json
|
|
1247
|
+
payload = {
|
|
1248
|
+
"sandbox": target_name,
|
|
1249
|
+
"stats": stats,
|
|
1250
|
+
"events": [ev.to_dict() for ev in recent],
|
|
1251
|
+
}
|
|
1252
|
+
print(json.dumps(payload, indent=2))
|
|
1253
|
+
return 0
|
|
1254
|
+
|
|
1255
|
+
title_target = f"for '{target_name}'" if target_name else "Across All Sandboxes"
|
|
1256
|
+
console.print(f"\n[bold]Outbound Egress Traffic Audit {title_target}[/bold]")
|
|
1257
|
+
console.print("═" * 92)
|
|
1258
|
+
console.print(f" [bold]Total Requests:[/bold] {stats['total_requests']}")
|
|
1259
|
+
console.print(f" [bold]Unique Sites:[/bold] {stats['unique_domains']}")
|
|
1260
|
+
console.print(f" [bold]Allowed:[/bold] [green]{stats['allowed_requests']}[/green]")
|
|
1261
|
+
console.print(f" [bold]Blocked:[/bold] [red]{stats['blocked_requests']}[/red]")
|
|
1262
|
+
|
|
1263
|
+
if stats["by_category"]:
|
|
1264
|
+
console.print(f"\n [bold]Calls by Category:[/bold]")
|
|
1265
|
+
for cat, count in stats["by_category"].items():
|
|
1266
|
+
console.print(f" • {cat:<22}: {count} calls")
|
|
1267
|
+
|
|
1268
|
+
sites = stats["sites"][: max(1, limit)]
|
|
1269
|
+
if sites:
|
|
1270
|
+
table_sites = Table(box=rich_box.ROUNDED, header_style="bold cyan", title="Top Domain Destinations")
|
|
1271
|
+
table_sites.add_column("DOMAIN", style="bold white", no_wrap=True)
|
|
1272
|
+
table_sites.add_column("CATEGORY")
|
|
1273
|
+
table_sites.add_column("CALLS", justify="right", no_wrap=True)
|
|
1274
|
+
table_sites.add_column("ALLOWED", justify="right", no_wrap=True)
|
|
1275
|
+
table_sites.add_column("BLOCKED", justify="right", no_wrap=True)
|
|
1276
|
+
table_sites.add_column("LAST SEEN")
|
|
1277
|
+
for site in sites:
|
|
1278
|
+
dt = datetime.fromtimestamp(site["last_seen"]).strftime("%Y-%m-%d %H:%M:%S") if site["last_seen"] > 0 else "-"
|
|
1279
|
+
blk_str = f"[red]{site['blocked_count']}[/red]" if site["blocked_count"] > 0 else str(site["blocked_count"])
|
|
1280
|
+
table_sites.add_row(
|
|
1281
|
+
site["domain"],
|
|
1282
|
+
site["category"],
|
|
1283
|
+
str(site["total_count"]),
|
|
1284
|
+
f"[green]{site['allowed_count']}[/green]",
|
|
1285
|
+
blk_str,
|
|
1286
|
+
dt,
|
|
1287
|
+
)
|
|
1288
|
+
console.print()
|
|
1289
|
+
console.print(table_sites)
|
|
1290
|
+
else:
|
|
1291
|
+
console.print(f"\n [dim]No outbound domain calls recorded yet.[/dim]")
|
|
1292
|
+
|
|
1293
|
+
if recent:
|
|
1294
|
+
table_recent = Table(box=rich_box.ROUNDED, header_style="bold cyan", title="Recent Egress Events")
|
|
1295
|
+
table_recent.add_column("TIME", no_wrap=True)
|
|
1296
|
+
table_recent.add_column("SANDBOX")
|
|
1297
|
+
table_recent.add_column("ACTION", no_wrap=True)
|
|
1298
|
+
table_recent.add_column("PROTO", no_wrap=True)
|
|
1299
|
+
table_recent.add_column("DOMAIN", style="bold white", no_wrap=True)
|
|
1300
|
+
table_recent.add_column("REASON")
|
|
1301
|
+
for ev in recent:
|
|
1302
|
+
tm = datetime.fromtimestamp(ev.timestamp).strftime("%H:%M:%S") if ev.timestamp > 0 else "-"
|
|
1303
|
+
act_disp = "[red]BLOCKED[/red]" if ev.action == "blocked" else "[green]ALLOWED[/green]"
|
|
1304
|
+
table_recent.add_row(
|
|
1305
|
+
tm,
|
|
1306
|
+
ev.sandbox_name,
|
|
1307
|
+
act_disp,
|
|
1308
|
+
ev.protocol,
|
|
1309
|
+
ev.domain,
|
|
1310
|
+
ev.reason or "-",
|
|
1311
|
+
)
|
|
1312
|
+
console.print()
|
|
1313
|
+
console.print(table_recent)
|
|
1314
|
+
|
|
1315
|
+
console.print()
|
|
1316
|
+
return 0
|
|
1317
|
+
|
|
1318
|
+
|
|
1319
|
+
def cmd_open(manager: SandboxManager, name: str, port: Optional[int] = None) -> int:
|
|
1320
|
+
box = manager.get_sandbox(name)
|
|
1321
|
+
if not box:
|
|
1322
|
+
print(f"{RED}Error: Sandbox '{name}' not found.{RESET}", file=sys.stderr)
|
|
1323
|
+
return 1
|
|
1324
|
+
|
|
1325
|
+
all_ports = box.all_ports
|
|
1326
|
+
if not all_ports:
|
|
1327
|
+
print(f"{YELLOW}No ports forwarded for '{box.name}'.{RESET}")
|
|
1328
|
+
return 1
|
|
1329
|
+
|
|
1330
|
+
target = None
|
|
1331
|
+
if port:
|
|
1332
|
+
for p in all_ports:
|
|
1333
|
+
if p.guest_port == port or p.host_port == port:
|
|
1334
|
+
target = p
|
|
1335
|
+
break
|
|
1336
|
+
else:
|
|
1337
|
+
target = all_ports[0]
|
|
1338
|
+
|
|
1339
|
+
if not target:
|
|
1340
|
+
print(f"{RED}Port {port} not found on sandbox '{box.name}'.{RESET}", file=sys.stderr)
|
|
1341
|
+
return 1
|
|
1342
|
+
|
|
1343
|
+
url = f"http://127.0.0.1:{target.host_port}"
|
|
1344
|
+
print(f"{CYAN}Opening {url} in default browser...{RESET}")
|
|
1345
|
+
webbrowser.open(url)
|
|
1346
|
+
return 0
|
|
1347
|
+
|
|
1348
|
+
|
|
1349
|
+
def cmd_logs(manager: SandboxManager, name: str, tail: int = 100, follow: bool = False) -> int:
|
|
1350
|
+
box = manager.get_sandbox(name)
|
|
1351
|
+
if not box:
|
|
1352
|
+
print(f"{RED}Error: Sandbox '{name}' not found.{RESET}", file=sys.stderr)
|
|
1353
|
+
return 1
|
|
1354
|
+
|
|
1355
|
+
if follow:
|
|
1356
|
+
cmd = ["podman", "logs", "-f", "--tail", str(tail), box.container_name]
|
|
1357
|
+
return subprocess.run(cmd).returncode
|
|
1358
|
+
else:
|
|
1359
|
+
print(manager.get_logs(box.name, tail=tail))
|
|
1360
|
+
return 0
|
|
1361
|
+
|
|
1362
|
+
|
|
1363
|
+
def cmd_stats(manager: SandboxManager, json_output: bool = False) -> int:
|
|
1364
|
+
if json_output:
|
|
1365
|
+
import json
|
|
1366
|
+
print(json.dumps(manager.get_stats(), indent=2))
|
|
1367
|
+
return 0
|
|
1368
|
+
cmd = ["podman", "stats", "--filter", "label=krunbox=true"]
|
|
1369
|
+
return subprocess.run(cmd).returncode
|
|
1370
|
+
|
|
1371
|
+
|
|
1372
|
+
def cmd_api_stats(manager: SandboxManager, name: Optional[str] = None, json_output: bool = False) -> int:
|
|
1373
|
+
"""Display corporate AI API telemetry audit statistics."""
|
|
1374
|
+
summary = manager.telemetry.get_summary(sandbox_name=name)
|
|
1375
|
+
recent_calls = manager.telemetry.get_calls(sandbox_name=name, limit=15)
|
|
1376
|
+
|
|
1377
|
+
if json_output:
|
|
1378
|
+
import json
|
|
1379
|
+
payload = {
|
|
1380
|
+
"sandbox": name,
|
|
1381
|
+
"summary": summary,
|
|
1382
|
+
"recent_calls": [c.to_dict() for c in recent_calls],
|
|
1383
|
+
}
|
|
1384
|
+
print(json.dumps(payload, indent=2))
|
|
1385
|
+
return 0
|
|
1386
|
+
|
|
1387
|
+
title_target = f"for '{name}'" if name else "Across All Sandboxes"
|
|
1388
|
+
console.print(f"\n[bold]AI Model Invocation Audit {title_target}[/bold]")
|
|
1389
|
+
console.print("═" * 70)
|
|
1390
|
+
console.print(f" [bold]Total API Invocations:[/bold] {summary['total_calls']}")
|
|
1391
|
+
console.print(f" [bold]Cumulative Tokens:[/bold] {summary['total_tokens']:,}")
|
|
1392
|
+
console.print(f" [bold]Estimated Spend:[/bold] ${summary['estimated_cost_usd']:.4f} USD")
|
|
1393
|
+
console.print(f" [bold]Success / Health Rate:[/bold] {summary['success_rate']}% ({summary['error_count']} errors)")
|
|
1394
|
+
|
|
1395
|
+
if summary["by_provider"]:
|
|
1396
|
+
console.print(f"\n [bold]Invocations by AI Provider:[/bold]")
|
|
1397
|
+
for prov, count in summary["by_provider"].items():
|
|
1398
|
+
console.print(f" • {prov:<18}: {count} requests")
|
|
1399
|
+
|
|
1400
|
+
if summary["by_model"]:
|
|
1401
|
+
console.print(f"\n [bold]Invocations by Model Tag:[/bold]")
|
|
1402
|
+
for m, count in summary["by_model"].items():
|
|
1403
|
+
console.print(f" • {m:<28}: {count} calls")
|
|
1404
|
+
|
|
1405
|
+
if recent_calls:
|
|
1406
|
+
table_calls = Table(box=rich_box.ROUNDED, header_style="bold cyan", title="Recent AI Invocations")
|
|
1407
|
+
table_calls.add_column("TIME")
|
|
1408
|
+
table_calls.add_column("PROVIDER")
|
|
1409
|
+
table_calls.add_column("MODEL", style="bold white")
|
|
1410
|
+
table_calls.add_column("STATUS")
|
|
1411
|
+
table_calls.add_column("TOKENS", justify="right")
|
|
1412
|
+
for c in recent_calls:
|
|
1413
|
+
dt = datetime.fromtimestamp(c.timestamp).strftime("%H:%M:%S")
|
|
1414
|
+
st_color = "[green]" if c.status_code < 400 else "[red]"
|
|
1415
|
+
table_calls.add_row(
|
|
1416
|
+
dt,
|
|
1417
|
+
c.provider,
|
|
1418
|
+
c.model,
|
|
1419
|
+
f"{st_color}{c.status_code}[/]",
|
|
1420
|
+
str(c.total_tokens),
|
|
1421
|
+
)
|
|
1422
|
+
console.print()
|
|
1423
|
+
console.print(table_calls)
|
|
1424
|
+
|
|
1425
|
+
console.print()
|
|
1426
|
+
return 0
|
|
1427
|
+
|
|
1428
|
+
|
|
1429
|
+
def cmd_images(image_mgr: ImageManager, action: str = "list", target: Optional[str] = None, json_output: bool = False, force: bool = False) -> int:
|
|
1430
|
+
clean_action = (action or "list").lower().strip()
|
|
1431
|
+
if clean_action == "build":
|
|
1432
|
+
target_name = target or "all"
|
|
1433
|
+
if target_name in ("all", "*"):
|
|
1434
|
+
from rich.progress import (
|
|
1435
|
+
Progress,
|
|
1436
|
+
SpinnerColumn,
|
|
1437
|
+
TextColumn,
|
|
1438
|
+
BarColumn,
|
|
1439
|
+
TaskProgressColumn,
|
|
1440
|
+
TimeElapsedColumn,
|
|
1441
|
+
)
|
|
1442
|
+
|
|
1443
|
+
console.print("[bold cyan]Building all required base images...[/bold cyan]")
|
|
1444
|
+
all_ok = True
|
|
1445
|
+
with Progress(
|
|
1446
|
+
SpinnerColumn(),
|
|
1447
|
+
TextColumn("[bold cyan]{task.description}[/bold cyan]"),
|
|
1448
|
+
BarColumn(bar_width=30),
|
|
1449
|
+
TaskProgressColumn(),
|
|
1450
|
+
TimeElapsedColumn(),
|
|
1451
|
+
console=console,
|
|
1452
|
+
transient=False,
|
|
1453
|
+
) as progress:
|
|
1454
|
+
overall = progress.add_task("Preparing builds...", total=4)
|
|
1455
|
+
|
|
1456
|
+
def _progress_cb(target_key: str, line: str):
|
|
1457
|
+
if line.startswith("STEP ") or "-->" in line or "Successfully" in line:
|
|
1458
|
+
short = line[:55] + "..." if len(line) > 55 else line
|
|
1459
|
+
progress.update(
|
|
1460
|
+
overall,
|
|
1461
|
+
description=f"Building [bold white]{target_key}[/bold white] [dim]({short})[/dim]",
|
|
1462
|
+
)
|
|
1463
|
+
|
|
1464
|
+
def _item_complete(target_key: str, ok: bool, msg: str):
|
|
1465
|
+
progress.advance(overall)
|
|
1466
|
+
if ok:
|
|
1467
|
+
console.print(f" [green]✓[/green] [{target_key}] {msg}")
|
|
1468
|
+
else:
|
|
1469
|
+
console.print(f" [red]✗[/red] [{target_key}] {msg}")
|
|
1470
|
+
|
|
1471
|
+
results = image_mgr.build_all_images(
|
|
1472
|
+
on_progress=_progress_cb,
|
|
1473
|
+
on_image_complete=_item_complete,
|
|
1474
|
+
)
|
|
1475
|
+
|
|
1476
|
+
# Fallback if mock or method returned results without triggering on_image_complete
|
|
1477
|
+
if progress.tasks[overall].completed == 0 and results:
|
|
1478
|
+
for t, ok, msg in results:
|
|
1479
|
+
progress.advance(overall)
|
|
1480
|
+
if ok:
|
|
1481
|
+
console.print(f" [green]✓[/green] [{t}] {msg}")
|
|
1482
|
+
else:
|
|
1483
|
+
console.print(f" [red]✗[/red] [{t}] {msg}")
|
|
1484
|
+
|
|
1485
|
+
for t, ok, msg in results:
|
|
1486
|
+
if not ok:
|
|
1487
|
+
all_ok = False
|
|
1488
|
+
|
|
1489
|
+
if all_ok:
|
|
1490
|
+
progress.update(overall, description="[bold green]All base images built successfully[/bold green]")
|
|
1491
|
+
else:
|
|
1492
|
+
progress.update(overall, description="[bold red]Some base images failed to build[/bold red]")
|
|
1493
|
+
|
|
1494
|
+
return 0 if all_ok else 1
|
|
1495
|
+
|
|
1496
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn, TimeElapsedColumn
|
|
1497
|
+
|
|
1498
|
+
with Progress(
|
|
1499
|
+
SpinnerColumn(),
|
|
1500
|
+
TextColumn("[bold cyan]{task.description}[/bold cyan]"),
|
|
1501
|
+
TimeElapsedColumn(),
|
|
1502
|
+
console=console,
|
|
1503
|
+
transient=True,
|
|
1504
|
+
) as progress:
|
|
1505
|
+
task_id = progress.add_task(f"Building image target '{target_name}'...", total=None)
|
|
1506
|
+
|
|
1507
|
+
def _step_cb(line: str):
|
|
1508
|
+
if line.startswith("STEP ") or "-->" in line or "Successfully" in line:
|
|
1509
|
+
short = line[:60] + "..." if len(line) > 60 else line
|
|
1510
|
+
progress.update(task_id, description=f"Building [bold white]{target_name}[/bold white] [dim]({short})[/dim]")
|
|
1511
|
+
|
|
1512
|
+
ok, msg = image_mgr.build_image(target_name, on_progress=_step_cb)
|
|
1513
|
+
|
|
1514
|
+
if ok:
|
|
1515
|
+
console.print(f"[bold green]✓[/bold green] {msg}")
|
|
1516
|
+
return 0
|
|
1517
|
+
print(f"{RED}Error building image:{RESET} {msg}", file=sys.stderr)
|
|
1518
|
+
return 1
|
|
1519
|
+
|
|
1520
|
+
images = image_mgr.list_images()
|
|
1521
|
+
if json_output:
|
|
1522
|
+
import json
|
|
1523
|
+
print(json.dumps(images, indent=2))
|
|
1524
|
+
return 0
|
|
1525
|
+
|
|
1526
|
+
if clean_action in ("check", "status"):
|
|
1527
|
+
table = Table(box=rich_box.ROUNDED, header_style="bold cyan")
|
|
1528
|
+
table.add_column("ID", style="bold white", no_wrap=True)
|
|
1529
|
+
table.add_column("STATUS", no_wrap=True)
|
|
1530
|
+
table.add_column("AGE", justify="right", no_wrap=True)
|
|
1531
|
+
table.add_column("SPEC / REBUILD REASON")
|
|
1532
|
+
for img in images:
|
|
1533
|
+
if not img["is_present"]:
|
|
1534
|
+
st = "[red]MISSING[/red]"
|
|
1535
|
+
elif img.get("needs_rebuild"):
|
|
1536
|
+
st = "[yellow]OUTDATED[/yellow]"
|
|
1537
|
+
else:
|
|
1538
|
+
st = "[green]UP-TO-DATE[/green]"
|
|
1539
|
+
age_str = f"{img['age_days']}d" if img.get("age_days") is not None else "--"
|
|
1540
|
+
reason = img.get("rebuild_reason", "Ready")
|
|
1541
|
+
table.add_row(img["id"], st, age_str, reason)
|
|
1542
|
+
console.print()
|
|
1543
|
+
console.print(table)
|
|
1544
|
+
console.print()
|
|
1545
|
+
return 0
|
|
1546
|
+
|
|
1547
|
+
table = Table(box=rich_box.ROUNDED, header_style="bold cyan")
|
|
1548
|
+
table.add_column("ID", style="bold white", no_wrap=True)
|
|
1549
|
+
table.add_column("STATUS", no_wrap=True)
|
|
1550
|
+
table.add_column("TAG")
|
|
1551
|
+
table.add_column("DESCRIPTION")
|
|
1552
|
+
for img in images:
|
|
1553
|
+
if not img["is_present"]:
|
|
1554
|
+
st = "[dim]AVAILABLE[/dim]"
|
|
1555
|
+
elif img.get("needs_rebuild"):
|
|
1556
|
+
st = "[yellow]REBUILD[/yellow]"
|
|
1557
|
+
else:
|
|
1558
|
+
st = "[green]PRESENT[/green]"
|
|
1559
|
+
table.add_row(img["id"], st, img["tag"], img["description"])
|
|
1560
|
+
console.print()
|
|
1561
|
+
console.print(table)
|
|
1562
|
+
console.print()
|
|
1563
|
+
return 0
|
|
1564
|
+
|
|
1565
|
+
|
|
1566
|
+
def cmd_doctor(json_output: bool = False) -> int:
|
|
1567
|
+
doc = Doctor()
|
|
1568
|
+
checks = doc.check_all()
|
|
1569
|
+
if json_output:
|
|
1570
|
+
import json
|
|
1571
|
+
print(json.dumps(checks, indent=2))
|
|
1572
|
+
return 0
|
|
1573
|
+
console.print(f"\n[bold]krunbox System Diagnostics (Fedora 44 / Linux Host)[/bold]")
|
|
1574
|
+
console.print("═" * 70)
|
|
1575
|
+
all_ok = True
|
|
1576
|
+
table = Table(box=rich_box.ROUNDED, header_style="bold cyan")
|
|
1577
|
+
table.add_column("STATUS", justify="center", no_wrap=True)
|
|
1578
|
+
table.add_column("COMPONENT", style="bold white", no_wrap=True)
|
|
1579
|
+
table.add_column("MESSAGE")
|
|
1580
|
+
table.add_column("REMEDIATION / FIX")
|
|
1581
|
+
|
|
1582
|
+
for c in checks:
|
|
1583
|
+
if c["status"] == "ok":
|
|
1584
|
+
pill = "[green]✓ PASS[/green]"
|
|
1585
|
+
elif c["status"] == "warn":
|
|
1586
|
+
pill = "[yellow]! WARN[/yellow]"
|
|
1587
|
+
else:
|
|
1588
|
+
pill = "[red]✗ FAIL[/red]"
|
|
1589
|
+
all_ok = False
|
|
1590
|
+
fix = c.get("remediation") if (c.get("remediation") and c["status"] != "ok") else "-"
|
|
1591
|
+
table.add_row(pill, c["component"], c["message"], fix)
|
|
1592
|
+
|
|
1593
|
+
console.print(table)
|
|
1594
|
+
|
|
1595
|
+
if all_ok:
|
|
1596
|
+
console.print(f"\n[green]✓ Host environment fully primed for crun-krun microVM acceleration.[/green]\n")
|
|
1597
|
+
else:
|
|
1598
|
+
console.print(f"\n[yellow]! Some optional microVM capabilities missing. Review recommendations above.[/yellow]\n")
|
|
1599
|
+
return 0
|
|
1600
|
+
|
|
1601
|
+
|
|
1602
|
+
def cmd_prune(manager: SandboxManager) -> int:
|
|
1603
|
+
count = manager.prune()
|
|
1604
|
+
print(f"{GREEN}✓ Pruned {count} stopped ephemeral sandbox(es).{RESET}")
|
|
1605
|
+
return 0
|
|
1606
|
+
|
|
1607
|
+
|
|
1608
|
+
def cmd_install_desktop(system: bool = False) -> int:
|
|
1609
|
+
from ..core.desktop import install_desktop_integration
|
|
1610
|
+
res = install_desktop_integration(system=system)
|
|
1611
|
+
if res["success"]:
|
|
1612
|
+
print(f"\n{GREEN}✓ Installed {res['files_count']} desktop integration files.{RESET}")
|
|
1613
|
+
print(f" Target: {res['target_base']}")
|
|
1614
|
+
print(f" GNOME App Manager metadata & icon cache refreshed.\n")
|
|
1615
|
+
return 0
|
|
1616
|
+
print(f"{RED}✗ Desktop integration installation failed.{RESET}\n")
|
|
1617
|
+
return 1
|
|
1618
|
+
|
|
1619
|
+
|
|
1620
|
+
def cmd_requests(manager: SandboxManager, sandbox: Optional[str] = None, all_info: bool = False, json_output: bool = False) -> int:
|
|
1621
|
+
"""List pending or recent permission requests from agents in sandboxes."""
|
|
1622
|
+
if all_info and hasattr(manager, "ipc_server") and manager.ipc_server is not None:
|
|
1623
|
+
reqs = manager.ipc_server.get_all_requests(sandbox)
|
|
1624
|
+
elif hasattr(manager, "list_pending_requests"):
|
|
1625
|
+
reqs = manager.list_pending_requests(sandbox)
|
|
1626
|
+
elif hasattr(manager, "get_pending_access_requests"):
|
|
1627
|
+
reqs = manager.get_pending_access_requests(sandbox)
|
|
1628
|
+
else:
|
|
1629
|
+
reqs = []
|
|
1630
|
+
|
|
1631
|
+
if json_output:
|
|
1632
|
+
import json
|
|
1633
|
+
print(json.dumps([r.to_dict() for r in reqs], indent=2))
|
|
1634
|
+
return 0
|
|
1635
|
+
|
|
1636
|
+
if not reqs:
|
|
1637
|
+
scope = f" for '{sandbox}'" if sandbox else ""
|
|
1638
|
+
console.print(f"[dim]No pending agent access requests{scope}.[/dim]\n")
|
|
1639
|
+
return 0
|
|
1640
|
+
|
|
1641
|
+
table = Table(box=rich_box.ROUNDED, header_style="bold cyan", title="Agent Access Requests")
|
|
1642
|
+
table.add_column("REQUEST ID", style="bold white", no_wrap=True)
|
|
1643
|
+
table.add_column("SANDBOX")
|
|
1644
|
+
table.add_column("TYPE", no_wrap=True)
|
|
1645
|
+
table.add_column("TARGET")
|
|
1646
|
+
table.add_column("STATUS", no_wrap=True)
|
|
1647
|
+
table.add_column("REASON")
|
|
1648
|
+
|
|
1649
|
+
for r in reqs:
|
|
1650
|
+
st = r.status.upper()
|
|
1651
|
+
if st == "PENDING":
|
|
1652
|
+
st_color = "[yellow]PENDING[/yellow]"
|
|
1653
|
+
elif st == "APPROVED":
|
|
1654
|
+
st_color = "[green]APPROVED[/green]"
|
|
1655
|
+
elif st == "DENIED":
|
|
1656
|
+
st_color = "[red]DENIED[/red]"
|
|
1657
|
+
else:
|
|
1658
|
+
st_color = f"[dim]{st}[/dim]"
|
|
1659
|
+
|
|
1660
|
+
rtype = getattr(r, "request_type", getattr(r, "action", ""))
|
|
1661
|
+
if "port" in rtype:
|
|
1662
|
+
rtype_str = "[cyan]PORT[/cyan]"
|
|
1663
|
+
else:
|
|
1664
|
+
rtype_str = "[blue]DOMAIN[/blue]"
|
|
1665
|
+
|
|
1666
|
+
target = str(getattr(r, "target", "") or getattr(r, "domain", "") or getattr(r, "guest_port", ""))
|
|
1667
|
+
table.add_row(r.id, r.sandbox_name, rtype_str, target, st_color, r.reason or "")
|
|
1668
|
+
|
|
1669
|
+
console.print()
|
|
1670
|
+
console.print(table)
|
|
1671
|
+
console.print(f"\n[dim]Use 'krunbox approve <id>' or 'krunbox deny <id> --reason ...' to respond.[/dim]\n")
|
|
1672
|
+
return 0
|
|
1673
|
+
|
|
1674
|
+
|
|
1675
|
+
def cmd_clone(manager: SandboxManager, source: str, new_name: str) -> int:
|
|
1676
|
+
"""Clone an existing sandbox configuration to a new sandbox."""
|
|
1677
|
+
ok, msg, box = manager.clone_sandbox(source, new_name)
|
|
1678
|
+
if ok:
|
|
1679
|
+
print(f"\n{GREEN}✓ {msg}{RESET}")
|
|
1680
|
+
if box:
|
|
1681
|
+
print(f" SSH Port: {box.ssh_port}")
|
|
1682
|
+
print(f" Container: {box.container_name}\n")
|
|
1683
|
+
return 0
|
|
1684
|
+
print(f"\n{RED}✗ Failed to clone sandbox: {msg}{RESET}\n", file=sys.stderr)
|
|
1685
|
+
return 1
|
|
1686
|
+
|
|
1687
|
+
|
|
1688
|
+
def cmd_export(manager: SandboxManager, name: str, output: Optional[str] = None) -> int:
|
|
1689
|
+
"""Export sandbox configuration and snapshot to an archive."""
|
|
1690
|
+
out_path = Path(output) if output else Path(f"{name}.tar.gz")
|
|
1691
|
+
ok, msg = manager.export_sandbox(name, out_path)
|
|
1692
|
+
if ok:
|
|
1693
|
+
print(f"\n{GREEN}✓ {msg}{RESET}\n")
|
|
1694
|
+
return 0
|
|
1695
|
+
print(f"\n{RED}✗ {msg}{RESET}\n", file=sys.stderr)
|
|
1696
|
+
return 1
|
|
1697
|
+
|
|
1698
|
+
|
|
1699
|
+
def cmd_import(manager: SandboxManager, archive: str, new_name: Optional[str] = None) -> int:
|
|
1700
|
+
"""Import a sandbox from an archive."""
|
|
1701
|
+
arc_path = Path(archive)
|
|
1702
|
+
ok, msg, box = manager.import_sandbox(arc_path, new_name=new_name)
|
|
1703
|
+
if ok:
|
|
1704
|
+
print(f"\n{GREEN}✓ {msg}{RESET}")
|
|
1705
|
+
if box:
|
|
1706
|
+
print(f" Directory: {box.dir_path}")
|
|
1707
|
+
print(f" SSH Port: {box.ssh_port}\n")
|
|
1708
|
+
return 0
|
|
1709
|
+
print(f"\n{RED}✗ {msg}{RESET}\n", file=sys.stderr)
|
|
1710
|
+
return 1
|
|
1711
|
+
|
|
1712
|
+
|
|
1713
|
+
def cmd_daemon(manager: SandboxManager, interval: int = 15, once: bool = False) -> int:
|
|
1714
|
+
"""Run continuous background daemon evaluating guardrails and idle limits."""
|
|
1715
|
+
print(f"{BOLD}krunbox daemon{RESET} starting (evaluating guardrails every {interval}s)...")
|
|
1716
|
+
if once:
|
|
1717
|
+
alerts = manager.evaluate_guardrails(notify=True)
|
|
1718
|
+
print(f"Single pass completed: {len(alerts)} alerts generated.")
|
|
1719
|
+
return 0
|
|
1720
|
+
|
|
1721
|
+
import signal
|
|
1722
|
+
running = True
|
|
1723
|
+
|
|
1724
|
+
def sig_handler(_sig, _frame):
|
|
1725
|
+
nonlocal running
|
|
1726
|
+
print("\nShutting down krunbox daemon...")
|
|
1727
|
+
running = False
|
|
1728
|
+
|
|
1729
|
+
signal.signal(signal.SIGINT, sig_handler)
|
|
1730
|
+
signal.signal(signal.SIGTERM, sig_handler)
|
|
1731
|
+
|
|
1732
|
+
while running:
|
|
1733
|
+
try:
|
|
1734
|
+
alerts = manager.evaluate_guardrails(notify=True)
|
|
1735
|
+
for alert in alerts:
|
|
1736
|
+
print(f" [{alert.category.upper()}] {alert.message}")
|
|
1737
|
+
except Exception as exc:
|
|
1738
|
+
print(f"Daemon error in guardrail cycle: {exc}", file=sys.stderr)
|
|
1739
|
+
|
|
1740
|
+
for _ in range(max(1, interval)):
|
|
1741
|
+
if not running:
|
|
1742
|
+
break
|
|
1743
|
+
time.sleep(1)
|
|
1744
|
+
|
|
1745
|
+
print("krunbox daemon terminated cleanly.")
|
|
1746
|
+
return 0
|
|
1747
|
+
|
|
1748
|
+
|
|
1749
|
+
def cmd_completion(shell: str, install: bool = False) -> int:
|
|
1750
|
+
"""Generate or install shell completion scripts for bash, zsh, fish."""
|
|
1751
|
+
from .completion import get_completion_script, install_completion_script
|
|
1752
|
+
if install:
|
|
1753
|
+
ok, msg = install_completion_script(shell)
|
|
1754
|
+
if ok:
|
|
1755
|
+
print(f"\n{GREEN}✓ {msg}{RESET}\n")
|
|
1756
|
+
return 0
|
|
1757
|
+
print(f"\n{RED}✗ {msg}{RESET}\n", file=sys.stderr)
|
|
1758
|
+
return 1
|
|
1759
|
+
else:
|
|
1760
|
+
try:
|
|
1761
|
+
script = get_completion_script(shell)
|
|
1762
|
+
print(script, end="")
|
|
1763
|
+
return 0
|
|
1764
|
+
except ValueError as exc:
|
|
1765
|
+
print(f"\n{RED}✗ {exc}{RESET}\n", file=sys.stderr)
|
|
1766
|
+
return 1
|
|
1767
|
+
|
|
1768
|
+
|
|
1769
|
+
def cmd_approve(manager: SandboxManager, request_id: str) -> int:
|
|
1770
|
+
"""Approve an in-sandbox agent permission request."""
|
|
1771
|
+
ok, msg = manager.approve_access_request(request_id)
|
|
1772
|
+
if ok:
|
|
1773
|
+
print(f"\n{GREEN}✓ {msg}{RESET}\n")
|
|
1774
|
+
return 0
|
|
1775
|
+
print(f"\n{RED}✗ Failed to approve request '{request_id}': {msg}{RESET}\n")
|
|
1776
|
+
return 1
|
|
1777
|
+
|
|
1778
|
+
|
|
1779
|
+
def cmd_deny(manager: SandboxManager, request_id: str, reason: str = "Denied by operator") -> int:
|
|
1780
|
+
"""Deny an in-sandbox agent permission request."""
|
|
1781
|
+
ok, msg = manager.deny_access_request(request_id, reason=reason)
|
|
1782
|
+
if ok:
|
|
1783
|
+
print(f"\n{YELLOW}✓ {msg}{RESET}\n")
|
|
1784
|
+
return 0
|
|
1785
|
+
print(f"\n{RED}✗ Failed to deny request '{request_id}': {msg}{RESET}\n")
|
|
1786
|
+
return 1
|
|
1787
|
+
|
|
1788
|
+
|
|
1789
|
+
def cmd_keep_awake(manager: SandboxManager, name: str, indefinite: bool = False, extend_minutes: int = 30) -> int:
|
|
1790
|
+
"""Reset idle sleep timer or configure sandbox to never sleep."""
|
|
1791
|
+
ok, msg = manager.keep_awake(name, indefinite=indefinite, extend_minutes=extend_minutes)
|
|
1792
|
+
if ok:
|
|
1793
|
+
print(f"\n{GREEN}✓ {msg}{RESET}\n")
|
|
1794
|
+
return 0
|
|
1795
|
+
print(f"\n{RED}✗ {msg}{RESET}\n")
|
|
1796
|
+
return 1
|
|
1797
|
+
|
|
1798
|
+
|
|
1799
|
+
def cmd_ui() -> int:
|
|
1800
|
+
from ..ui.app import run_app
|
|
1801
|
+
return run_app()
|