bunkervm 0.2.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.
bunkervm/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """
2
+ BunkerVM — Self-hosted, hardware-isolated sandbox for AI agents.
3
+
4
+ Exposes a Firecracker MicroVM as MCP tools that any AI model
5
+ (Claude, GPT, Ollama, LangGraph) can use for safe command execution.
6
+ """
7
+
8
+ __version__ = "0.1.0"
bunkervm/__main__.py ADDED
@@ -0,0 +1,155 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ BunkerVM MCP Server — Entry point.
4
+
5
+ Usage:
6
+ python -m bunkervm # Boots VM with internet. Needs sudo.
7
+ python -m bunkervm --transport sse # SSE transport (remote/web clients)
8
+ python -m bunkervm --no-network # Offline mode (no internet in VM)
9
+ python -m bunkervm --skip-vm # VM already running externally
10
+ python -m bunkervm --help
11
+
12
+ Claude Desktop config (claude_desktop_config.json):
13
+ {
14
+ "mcpServers": {
15
+ "bunkervm": {
16
+ "command": "wsl",
17
+ "args": ["-d", "Ubuntu", "--", "sudo", "python3", "-m", "bunkervm"]
18
+ }
19
+ }
20
+ }
21
+
22
+ One line. VM boots with internet access. sudo needed for TAP networking.
23
+ """
24
+
25
+ import argparse
26
+ import atexit
27
+ import logging
28
+ import sys
29
+
30
+
31
+ def main():
32
+ parser = argparse.ArgumentParser(
33
+ prog="bunkervm",
34
+ description="BunkerVM — Hardware-isolated sandbox for AI agents",
35
+ )
36
+ parser.add_argument(
37
+ "--transport", choices=["stdio", "sse"], default="stdio",
38
+ help="MCP transport: stdio (default, for Claude) or sse (remote)",
39
+ )
40
+ parser.add_argument(
41
+ "--port", type=int, default=3000,
42
+ help="Port for SSE transport (default: 3000)",
43
+ )
44
+ parser.add_argument(
45
+ "--config", default=None,
46
+ help="Path to bunkervm.toml config file",
47
+ )
48
+ parser.add_argument(
49
+ "--no-network", action="store_true",
50
+ help="Disable TAP networking (no internet in VM, no sudo needed)",
51
+ )
52
+ parser.add_argument(
53
+ "--skip-vm", action="store_true",
54
+ help="Don't start VM (assume externally managed)",
55
+ )
56
+ parser.add_argument(
57
+ "--vm-ip", default=None,
58
+ help="Override VM IP (only with --network or --skip-vm)",
59
+ )
60
+ parser.add_argument(
61
+ "--vm-port", type=int, default=None,
62
+ help="Override VM exec-agent port",
63
+ )
64
+ parser.add_argument(
65
+ "-v", "--verbose", action="store_true",
66
+ help="Enable debug logging",
67
+ )
68
+ args = parser.parse_args()
69
+
70
+ # Logging to stderr (stdout reserved for MCP stdio protocol)
71
+ logging.basicConfig(
72
+ level=logging.DEBUG if args.verbose else logging.INFO,
73
+ format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
74
+ stream=sys.stderr,
75
+ )
76
+ logger = logging.getLogger("bunkervm")
77
+
78
+ # ── Load config ──
79
+ from .config import load_config
80
+ config = load_config(args.config)
81
+
82
+ if args.vm_ip:
83
+ config.vm_ip = args.vm_ip
84
+ if args.vm_port:
85
+ config.vm_port = args.vm_port
86
+
87
+ # ── Audit logger ──
88
+ from .audit import AuditLogger
89
+ audit = AuditLogger(config.audit_log_path)
90
+ network = not args.no_network
91
+ audit.log("server_start", transport=args.transport, network=network)
92
+
93
+ # ── Bootstrap: ensure BunkerVM bundle is ready ──
94
+ if not args.skip_vm:
95
+ from .bootstrap import ensure_ready
96
+
97
+ bundle = ensure_ready()
98
+ # Override config paths with bootstrap-provided paths
99
+ config.firecracker_bin = bundle.firecracker
100
+ config.kernel_path = bundle.kernel
101
+ config.rootfs_path = bundle.rootfs
102
+
103
+ # ── Start VM ──
104
+ vm = None
105
+ if not args.skip_vm:
106
+ from .vm_manager import VMManager
107
+
108
+ vm = VMManager(config, network=network)
109
+ try:
110
+ vm.start()
111
+ logger.info("VM started (PID %d)", vm.fc_pid)
112
+ atexit.register(vm.cleanup)
113
+ except Exception as e:
114
+ logger.error("Failed to start VM: %s", e)
115
+ sys.exit(1)
116
+
117
+ # ── Connect to sandbox ──
118
+ from .sandbox_client import SandboxClient
119
+
120
+ if args.skip_vm and args.vm_ip:
121
+ # External VM via TCP
122
+ client = SandboxClient(host=args.vm_ip, port=args.vm_port or config.vm_port)
123
+ else:
124
+ # Default: vsock (works with or without TAP)
125
+ client = SandboxClient(vsock_uds=config.vsock_uds_path, vsock_port=config.vm_port)
126
+
127
+ logger.info("Connecting to sandbox via %s...", client.label)
128
+
129
+ if client.wait_for_health(timeout=config.health_timeout):
130
+ logger.info("Sandbox ready!")
131
+ else:
132
+ if args.skip_vm:
133
+ logger.warning("Sandbox not responding — tools will fail until VM is available")
134
+ else:
135
+ logger.error("Sandbox did not become ready in time")
136
+ sys.exit(1)
137
+
138
+ # ── Start MCP server ──
139
+ from .mcp_server import create_server, set_globals
140
+
141
+ set_globals(client=client, audit=audit, vm_manager=vm, config=config)
142
+ server = create_server()
143
+
144
+ logger.info("BunkerVM MCP server ready (transport: %s)", args.transport)
145
+ audit.log("server_ready", transport=args.transport)
146
+
147
+ if args.transport == "sse":
148
+ logger.info("SSE endpoint: http://0.0.0.0:%d/sse", args.port)
149
+ server.run(transport="sse", port=args.port)
150
+ else:
151
+ server.run(transport="stdio")
152
+
153
+
154
+ if __name__ == "__main__":
155
+ main()
bunkervm/audit.py ADDED
@@ -0,0 +1,103 @@
1
+ """
2
+ BunkerVM Audit Logger — Structured event logging (JSONL).
3
+
4
+ Logs all sandbox operations to a JSONL file for audit, debugging,
5
+ and compliance. Each line is a self-contained JSON object with:
6
+ - ISO 8601 timestamp
7
+ - Monotonic sequence number
8
+ - Event type
9
+ - Event-specific payload
10
+
11
+ Thread-safe: uses a lock for concurrent writes.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import logging
18
+ import os
19
+ import threading
20
+ import time
21
+ from typing import Any
22
+
23
+ logger = logging.getLogger("bunkervm.audit")
24
+
25
+ _DEFAULT_LOG_DIR = os.path.expanduser("~/.bunkervm/logs")
26
+ _DEFAULT_LOG_FILE = "audit.jsonl"
27
+
28
+
29
+ class AuditLogger:
30
+ """Append-only JSONL audit logger.
31
+
32
+ Usage:
33
+ audit = AuditLogger("/var/log/bunkervm/audit.jsonl")
34
+ audit.log("exec", command="ls -la", exit_code=0)
35
+ audit.log("sandbox_reset")
36
+ """
37
+
38
+ def __init__(self, log_path: str | None = None):
39
+ if log_path is None:
40
+ log_path = os.path.join(_DEFAULT_LOG_DIR, _DEFAULT_LOG_FILE)
41
+
42
+ self.log_path = log_path
43
+ self._lock = threading.Lock()
44
+ self._sequence = 0
45
+
46
+ # Ensure directory exists
47
+ log_dir = os.path.dirname(self.log_path)
48
+ if log_dir:
49
+ os.makedirs(log_dir, exist_ok=True)
50
+
51
+ logger.info("Audit log: %s", self.log_path)
52
+
53
+ def log(self, event_type: str, **kwargs: Any) -> None:
54
+ """Append an audit event.
55
+
56
+ Args:
57
+ event_type: Event name (e.g., "exec", "read_file", "sandbox_reset").
58
+ **kwargs: Arbitrary key-value pairs for the event payload.
59
+ """
60
+ with self._lock:
61
+ self._sequence += 1
62
+ entry = {
63
+ "seq": self._sequence,
64
+ "ts": time.time(),
65
+ "iso": time.strftime("%Y-%m-%dT%H:%M:%S%z", time.localtime()),
66
+ "event": event_type,
67
+ **kwargs,
68
+ }
69
+
70
+ try:
71
+ line = json.dumps(entry, default=str, ensure_ascii=False)
72
+ with open(self.log_path, "a", encoding="utf-8") as f:
73
+ f.write(line + "\n")
74
+ except Exception as e:
75
+ # Never let audit failures break the server
76
+ logger.error("Failed to write audit log: %s", e)
77
+
78
+ def read_recent(self, n: int = 50) -> list[dict]:
79
+ """Read the last N audit entries. For debugging."""
80
+ if not os.path.exists(self.log_path):
81
+ return []
82
+
83
+ try:
84
+ with open(self.log_path, "r", encoding="utf-8") as f:
85
+ lines = f.readlines()
86
+
87
+ entries = []
88
+ for line in lines[-n:]:
89
+ line = line.strip()
90
+ if line:
91
+ try:
92
+ entries.append(json.loads(line))
93
+ except json.JSONDecodeError:
94
+ pass
95
+ return entries
96
+ except Exception as e:
97
+ logger.error("Failed to read audit log: %s", e)
98
+ return []
99
+
100
+ @property
101
+ def entry_count(self) -> int:
102
+ """Approximate number of log entries."""
103
+ return self._sequence
bunkervm/bootstrap.py ADDED
@@ -0,0 +1,288 @@
1
+ """
2
+ BunkerVM Bootstrap — Zero-config first-run setup.
3
+
4
+ On first run, automatically downloads the pre-built BunkerVM bundle:
5
+ - firecracker (static binary)
6
+ - vmlinux (Linux kernel)
7
+ - rootfs.ext4 (BunkerVM micro-OS — Alpine + Python + exec_agent)
8
+
9
+ Everything goes into ~/.bunkervm/. Users never touch build scripts.
10
+
11
+ from .bootstrap import ensure_ready
12
+ paths = ensure_ready() # Downloads if needed, returns paths
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ import os
19
+ import platform
20
+ import shutil
21
+ import stat
22
+ import sys
23
+ import tarfile
24
+ import urllib.error
25
+ import urllib.request
26
+ from dataclasses import dataclass
27
+ from pathlib import Path
28
+
29
+ logger = logging.getLogger("bunkervm.bootstrap")
30
+
31
+ # ── Constants ──
32
+
33
+ BUNKERVM_HOME = Path.home() / ".bunkervm"
34
+ BUNDLE_DIR = BUNKERVM_HOME / "bundle"
35
+ META_FILE = BUNDLE_DIR / "bundle.json"
36
+
37
+ # GitHub release config
38
+ GITHUB_REPO = "ashishgituser/bunkervm"
39
+ BUNDLE_FILENAME = "bunkervm-bundle-x86_64.tar.gz"
40
+
41
+ # Expected files in the bundle
42
+ REQUIRED_FILES = {
43
+ "firecracker": "firecracker",
44
+ "kernel": "vmlinux",
45
+ "rootfs": "rootfs.ext4",
46
+ }
47
+
48
+
49
+ @dataclass
50
+ class BundlePaths:
51
+ """Paths to the BunkerVM bundle components."""
52
+ firecracker: str
53
+ kernel: str
54
+ rootfs: str
55
+ home: str
56
+
57
+ @property
58
+ def ready(self) -> bool:
59
+ return all(os.path.exists(p) for p in [self.firecracker, self.kernel, self.rootfs])
60
+
61
+
62
+ def ensure_ready(version: Optional[str] = None, force: bool = False) -> BundlePaths:
63
+ """Ensure BunkerVM bundle is downloaded and ready.
64
+
65
+ This is the main entry point. Call it before starting the VM.
66
+ On first run, downloads everything automatically.
67
+ On subsequent runs, returns instantly.
68
+
69
+ Args:
70
+ version: Specific release version (default: latest)
71
+ force: Force re-download even if bundle exists
72
+
73
+ Returns:
74
+ BundlePaths with firecracker, kernel, rootfs paths
75
+ """
76
+ paths = _get_paths()
77
+
78
+ if paths.ready and not force:
79
+ logger.debug("Bundle ready at %s", BUNDLE_DIR)
80
+ return paths
81
+
82
+ # First run — need to download
83
+ _print_status("BunkerVM first-run setup...")
84
+
85
+ # Check prerequisites
86
+ _check_prerequisites()
87
+
88
+ # Create directories
89
+ BUNDLE_DIR.mkdir(parents=True, exist_ok=True)
90
+
91
+ # Try downloading pre-built bundle from GitHub Releases
92
+ if _download_bundle(version):
93
+ paths = _get_paths()
94
+ if paths.ready:
95
+ _print_status("BunkerVM ready! VM will boot in ~2 seconds.\n")
96
+ return paths
97
+
98
+ # Fallback: check if files exist in the project's build/ dir (dev mode)
99
+ paths = _try_dev_mode()
100
+ if paths and paths.ready:
101
+ _print_status("Using local build (dev mode).\n")
102
+ return paths
103
+
104
+ # Nothing worked
105
+ raise RuntimeError(
106
+ "BunkerVM bundle not found.\n\n"
107
+ "Options:\n"
108
+ f" 1. Download a release from: https://github.com/{GITHUB_REPO}/releases\n"
109
+ f" Extract to: {BUNDLE_DIR}\n\n"
110
+ " 2. Build locally (for contributors):\n"
111
+ " sudo bash build/setup-firecracker.sh\n"
112
+ " sudo bash build/build-sandbox-rootfs.sh\n"
113
+ )
114
+
115
+
116
+ def _get_paths() -> BundlePaths:
117
+ """Get bundle file paths (may not exist yet)."""
118
+ return BundlePaths(
119
+ firecracker=str(BUNDLE_DIR / REQUIRED_FILES["firecracker"]),
120
+ kernel=str(BUNDLE_DIR / REQUIRED_FILES["kernel"]),
121
+ rootfs=str(BUNDLE_DIR / REQUIRED_FILES["rootfs"]),
122
+ home=str(BUNKERVM_HOME),
123
+ )
124
+
125
+
126
+ def _check_prerequisites() -> None:
127
+ """Verify the host can run Firecracker."""
128
+ arch = platform.machine()
129
+ if arch not in ("x86_64", "amd64", "AMD64"):
130
+ _print_status(f"Warning: BunkerVM is built for x86_64, detected {arch}")
131
+
132
+ # Check if we're on Linux (or WSL)
133
+ if sys.platform != "linux":
134
+ # We might be on Windows calling into WSL — that's fine
135
+ # The actual VM runs in WSL/Linux
136
+ logger.debug("Not on Linux directly (platform: %s)", sys.platform)
137
+
138
+ # Check /dev/kvm
139
+ if sys.platform == "linux" and not os.path.exists("/dev/kvm"):
140
+ _print_status(
141
+ "Warning: /dev/kvm not found. KVM is required for Firecracker.\n"
142
+ " WSL2: Add to .wslconfig:\n"
143
+ " [wsl2]\n"
144
+ " nestedVirtualization=true\n"
145
+ )
146
+
147
+
148
+ def _download_bundle(version: Optional[str] = None) -> bool:
149
+ """Download the pre-built bundle from GitHub Releases.
150
+
151
+ Returns True if download succeeded.
152
+ """
153
+ try:
154
+ # Get download URL
155
+ if version:
156
+ url = f"https://github.com/{GITHUB_REPO}/releases/download/{version}/{BUNDLE_FILENAME}"
157
+ else:
158
+ # Latest release
159
+ url = f"https://github.com/{GITHUB_REPO}/releases/latest/download/{BUNDLE_FILENAME}"
160
+
161
+ _print_status(f"Downloading BunkerVM bundle (~100MB)...")
162
+ _print_status(f" From: {url}")
163
+
164
+ # Download to temp file
165
+ tmp_path = BUNKERVM_HOME / f".{BUNDLE_FILENAME}.tmp"
166
+ tmp_path.parent.mkdir(parents=True, exist_ok=True)
167
+
168
+ try:
169
+ _download_with_progress(url, str(tmp_path))
170
+ except (urllib.error.URLError, urllib.error.HTTPError, OSError) as e:
171
+ logger.debug("Download failed: %s", e)
172
+ _print_status(f" Download not available yet (this is expected for unreleased versions)")
173
+ if tmp_path.exists():
174
+ tmp_path.unlink()
175
+ return False
176
+
177
+ # Extract
178
+ _print_status(" Extracting...")
179
+ try:
180
+ with tarfile.open(str(tmp_path), "r:gz") as tar:
181
+ # Security: check for path traversal
182
+ for member in tar.getmembers():
183
+ if member.name.startswith("/") or ".." in member.name:
184
+ raise ValueError(f"Unsafe path in archive: {member.name}")
185
+ tar.extractall(path=str(BUNDLE_DIR))
186
+ except Exception as e:
187
+ _print_status(f" Extraction failed: {e}")
188
+ if tmp_path.exists():
189
+ tmp_path.unlink()
190
+ return False
191
+
192
+ # Clean up temp file
193
+ if tmp_path.exists():
194
+ tmp_path.unlink()
195
+
196
+ # Make firecracker executable
197
+ fc_path = BUNDLE_DIR / REQUIRED_FILES["firecracker"]
198
+ if fc_path.exists():
199
+ fc_path.chmod(fc_path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
200
+
201
+ _print_status(" Done!")
202
+ return True
203
+
204
+ except Exception as e:
205
+ logger.debug("Bundle download failed: %s", e)
206
+ return False
207
+
208
+
209
+ def _download_with_progress(url: str, dest: str) -> None:
210
+ """Download a file with progress indication."""
211
+ req = urllib.request.Request(url, headers={"User-Agent": "BunkerVM-Bootstrap/0.1"})
212
+ response = urllib.request.urlopen(req, timeout=120)
213
+
214
+ total = int(response.headers.get("Content-Length", 0))
215
+ downloaded = 0
216
+ chunk_size = 1024 * 1024 # 1MB chunks
217
+
218
+ with open(dest, "wb") as f:
219
+ while True:
220
+ chunk = response.read(chunk_size)
221
+ if not chunk:
222
+ break
223
+ f.write(chunk)
224
+ downloaded += len(chunk)
225
+ if total > 0:
226
+ pct = int(downloaded * 100 / total)
227
+ mb_done = downloaded / (1024 * 1024)
228
+ mb_total = total / (1024 * 1024)
229
+ _print_status(f" [{pct:3d}%] {mb_done:.0f}/{mb_total:.0f} MB", end="\r")
230
+
231
+ if total > 0:
232
+ _print_status(f" [100%] {total / (1024*1024):.0f} MB ")
233
+
234
+
235
+ def _try_dev_mode() -> Optional[BundlePaths]:
236
+ """Check if bundle files exist in the project build/ directory (dev mode).
237
+
238
+ This allows contributors who build locally to skip the download.
239
+ Files are symlinked (or copied) into ~/.bunkervm/bundle/ for consistency.
240
+ """
241
+ # Find project root by looking for bunkervm.toml
242
+ candidates = [
243
+ Path.cwd(),
244
+ Path(__file__).parent.parent, # bunkervm/../
245
+ ]
246
+
247
+ for project_root in candidates:
248
+ kernel = project_root / "build" / "vmlinux"
249
+ rootfs = project_root / "build" / "rootfs.ext4"
250
+
251
+ if kernel.exists() and rootfs.exists():
252
+ logger.info("Found local build at %s", project_root)
253
+
254
+ # Check for firecracker
255
+ fc_bin = shutil.which("firecracker")
256
+ if not fc_bin:
257
+ fc_bin = "/usr/local/bin/firecracker"
258
+ if not os.path.exists(fc_bin):
259
+ _print_status(" Firecracker binary not found. Run: sudo bash build/setup-firecracker.sh")
260
+ return None
261
+
262
+ # Symlink/copy into bundle dir for consistency
263
+ BUNDLE_DIR.mkdir(parents=True, exist_ok=True)
264
+
265
+ _link_or_copy(fc_bin, BUNDLE_DIR / "firecracker")
266
+ _link_or_copy(str(kernel), BUNDLE_DIR / "vmlinux")
267
+ _link_or_copy(str(rootfs), BUNDLE_DIR / "rootfs.ext4")
268
+
269
+ return _get_paths()
270
+
271
+ return None
272
+
273
+
274
+ def _link_or_copy(src: str, dst: Path) -> None:
275
+ """Symlink or copy a file into the bundle directory."""
276
+ if dst.exists() or dst.is_symlink():
277
+ dst.unlink()
278
+
279
+ try:
280
+ dst.symlink_to(src)
281
+ except OSError:
282
+ # Symlinks might fail on some filesystems
283
+ shutil.copy2(src, str(dst))
284
+
285
+
286
+ def _print_status(msg: str, end: str = "\n") -> None:
287
+ """Print bootstrap status to stderr (stdout is for MCP protocol)."""
288
+ print(msg, file=sys.stderr, end=end, flush=True)