vaultrun-sdk 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.
@@ -0,0 +1,42 @@
1
+ """VaultRun Python SDK — secure sandbox runtime for AI agents."""
2
+
3
+ from .client import (
4
+ Client,
5
+ Session,
6
+ Run,
7
+ AsyncRunResult,
8
+ File,
9
+ APIKey,
10
+ CreatedKey,
11
+ AuditLog,
12
+ StreamResult,
13
+ Organization,
14
+ OrgMember,
15
+ Snapshot,
16
+ SharedArtifact,
17
+ Image,
18
+ SessionStats,
19
+ PullStatus,
20
+ VaultRunError,
21
+ )
22
+
23
+ __all__ = [
24
+ "Client",
25
+ "Session",
26
+ "Run",
27
+ "AsyncRunResult",
28
+ "File",
29
+ "APIKey",
30
+ "CreatedKey",
31
+ "AuditLog",
32
+ "StreamResult",
33
+ "Organization",
34
+ "OrgMember",
35
+ "Snapshot",
36
+ "SharedArtifact",
37
+ "Image",
38
+ "SessionStats",
39
+ "PullStatus",
40
+ "VaultRunError",
41
+ ]
42
+ __version__ = "0.1.0"
sandbox_sdk/client.py ADDED
@@ -0,0 +1,902 @@
1
+ """VaultRun API client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import hmac
7
+ import io
8
+ import json
9
+ import os
10
+ from dataclasses import dataclass, field
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+ from typing import IO, Any, Generator, Iterator, Optional
14
+
15
+ import requests
16
+
17
+
18
+ class VaultRunError(Exception):
19
+ """Raised when the VaultRun API returns an error response."""
20
+
21
+ def __init__(self, status_code: int, message: str) -> None:
22
+ self.status_code = status_code
23
+ super().__init__(f"VaultRun API error {status_code}: {message}")
24
+
25
+
26
+ @dataclass
27
+ class Session:
28
+ id: str
29
+ image: str
30
+ status: str
31
+ network_enabled: bool
32
+ cpu_limit: float
33
+ memory_limit_mb: int
34
+ timeout_seconds: int
35
+ created_at: str
36
+ labels: dict[str, str] = field(default_factory=dict)
37
+ name: Optional[str] = None
38
+ container_id: Optional[str] = None
39
+ stopped_at: Optional[str] = None
40
+
41
+
42
+ @dataclass
43
+ class Run:
44
+ id: str
45
+ session_id: str
46
+ command: str
47
+ args: list[str]
48
+ status: str
49
+ timeout_seconds: int
50
+ created_at: str
51
+ exit_code: Optional[int] = None
52
+ stdout: Optional[str] = None
53
+ stderr: Optional[str] = None
54
+ duration_ms: Optional[int] = None
55
+ output_truncated: bool = False
56
+ started_at: Optional[str] = None
57
+ finished_at: Optional[str] = None
58
+
59
+
60
+ @dataclass
61
+ class AsyncRunResult:
62
+ """Returned by run_async — contains the pending run's ID."""
63
+ run_id: str
64
+ status: str
65
+ message: str
66
+
67
+
68
+ @dataclass
69
+ class File:
70
+ id: str
71
+ session_id: str
72
+ path: str
73
+ size_bytes: int
74
+ content_type: str
75
+ created_at: str
76
+
77
+
78
+ @dataclass
79
+ class AuditLog:
80
+ """A single audit trail entry."""
81
+ id: str
82
+ actor: str
83
+ action: str
84
+ timestamp: str
85
+ session_id: Optional[str] = None
86
+ run_id: Optional[str] = None
87
+ metadata: Optional[dict[str, Any]] = None
88
+
89
+
90
+ @dataclass
91
+ class StreamResult:
92
+ """Returned by :meth:`Client.stream` when the SSE stream closes."""
93
+ run_id: str
94
+ status: str
95
+ exit_code: Optional[int] = None
96
+ duration_ms: Optional[int] = None
97
+
98
+
99
+ @dataclass
100
+ class APIKey:
101
+ id: str
102
+ name: str
103
+ prefix: str
104
+ active: bool
105
+ created_at: str
106
+ last_used_at: Optional[str] = None
107
+ expires_at: Optional[str] = None
108
+ org_id: Optional[str] = None
109
+
110
+
111
+ @dataclass
112
+ class CreatedKey(APIKey):
113
+ key: str = ""
114
+
115
+
116
+ @dataclass
117
+ class Organization:
118
+ """A VaultRun team / org."""
119
+ id: str
120
+ name: str
121
+ slug: str
122
+ created_at: str
123
+ updated_at: str
124
+
125
+
126
+ @dataclass
127
+ class OrgMember:
128
+ """An actor's membership in an org with a RBAC role."""
129
+ org_id: str
130
+ principal: str
131
+ role: str # "viewer" | "executor" | "admin"
132
+ created_at: str
133
+
134
+
135
+ @dataclass
136
+ class Snapshot:
137
+ """A compressed workspace archive."""
138
+ id: str
139
+ session_id: str
140
+ name: str
141
+ created_by: str
142
+ size_bytes: int
143
+ created_at: str
144
+
145
+
146
+ @dataclass
147
+ class SharedArtifact:
148
+ """A file promoted to the shared artifact registry."""
149
+ id: str
150
+ name: str
151
+ size_bytes: int
152
+ content_type: str
153
+ created_by: str
154
+ created_at: str
155
+ session_id: Optional[str] = None
156
+
157
+
158
+ @dataclass
159
+ class Image:
160
+ """A Docker image available on the VaultRun host."""
161
+ id: str
162
+ tags: list[str]
163
+ size_bytes: int
164
+ created_at: str
165
+
166
+
167
+ @dataclass
168
+ class SessionStats:
169
+ """Live resource usage for a running session."""
170
+ session_id: str
171
+ cpu_percent: float
172
+ memory_usage_mb: float
173
+ memory_limit_mb: int
174
+ pids: int
175
+
176
+
177
+ @dataclass
178
+ class PullStatus:
179
+ """Result of a Docker image pull operation."""
180
+ image: str
181
+ status: str
182
+ message: str
183
+
184
+
185
+ class Client:
186
+ """VaultRun API client.
187
+
188
+ Example::
189
+
190
+ from sandbox_sdk import Client
191
+
192
+ client = Client("http://localhost:8080", api_key="vr_...")
193
+ session = client.create_session(image="python:3.12-slim")
194
+ client.upload_file(session.id, "script.py", open("script.py", "rb"))
195
+ result = client.run(session.id, command="python", args=["script.py"])
196
+ print(result.stdout)
197
+ client.delete_session(session.id)
198
+ """
199
+
200
+ def __init__(
201
+ self,
202
+ base_url: str,
203
+ api_key: str = "",
204
+ timeout: int = 120,
205
+ ) -> None:
206
+ self.base_url = base_url.rstrip("/")
207
+ self.api_key = api_key or os.environ.get("VAULTRUN_API_KEY", "")
208
+ self._session = requests.Session()
209
+ self._session.headers["X-API-Key"] = self.api_key
210
+ self._timeout = timeout
211
+
212
+ # --- Sessions ---
213
+
214
+ def create_session(
215
+ self,
216
+ *,
217
+ name: str = "",
218
+ image: str = "python:3.12-slim",
219
+ network_enabled: bool = False,
220
+ cpu_limit: float = 1.0,
221
+ memory_limit_mb: int = 512,
222
+ timeout_seconds: int = 300,
223
+ labels: dict[str, str] | None = None,
224
+ ) -> Session:
225
+ """Create a new sandbox session."""
226
+ body: dict[str, Any] = {
227
+ "image": image,
228
+ "network_enabled": network_enabled,
229
+ "cpu_limit": cpu_limit,
230
+ "memory_limit_mb": memory_limit_mb,
231
+ "timeout_seconds": timeout_seconds,
232
+ }
233
+ if name:
234
+ body["name"] = name
235
+ if labels:
236
+ body["labels"] = labels
237
+
238
+ data = self._post("/api/v1/sessions", body)
239
+ return self._parse_session(data)
240
+
241
+ def get_session(self, session_id: str) -> Session:
242
+ """Get a session by ID."""
243
+ return self._parse_session(self._get(f"/api/v1/sessions/{session_id}"))
244
+
245
+ def list_sessions(
246
+ self,
247
+ *,
248
+ page: int = 1,
249
+ limit: int = 20,
250
+ label: str = "",
251
+ ) -> list[Session]:
252
+ """List sessions, newest first. Use page/limit to paginate.
253
+
254
+ Pass label="key:value" to filter by a specific label.
255
+ """
256
+ url = f"/api/v1/sessions?page={page}&limit={limit}"
257
+ if label:
258
+ url += f"&label={label}"
259
+ data = self._get(url)
260
+ return [self._parse_session(s) for s in data.get("sessions", [])]
261
+
262
+ def delete_session(self, session_id: str) -> None:
263
+ """Delete a session and its container."""
264
+ self._delete(f"/api/v1/sessions/{session_id}")
265
+
266
+ def update_labels(
267
+ self,
268
+ session_id: str,
269
+ labels: dict[str, str],
270
+ ) -> dict[str, str]:
271
+ """Replace the label set on a session. Pass {} to clear all labels."""
272
+ data = self._patch(
273
+ f"/api/v1/sessions/{session_id}/labels",
274
+ {"labels": labels},
275
+ )
276
+ return data.get("labels", {})
277
+
278
+ # --- Command execution ---
279
+
280
+ def run(
281
+ self,
282
+ session_id: str,
283
+ *,
284
+ command: str,
285
+ args: list[str] | None = None,
286
+ env: dict[str, str] | None = None,
287
+ working_dir: str = "",
288
+ timeout_seconds: int = 30,
289
+ ) -> Run:
290
+ """Execute a command inside a session and return the result (blocking)."""
291
+ body: dict[str, Any] = {
292
+ "command": command,
293
+ "args": args or [],
294
+ "timeout_seconds": timeout_seconds,
295
+ }
296
+ if env:
297
+ body["env"] = env
298
+ if working_dir:
299
+ body["working_dir"] = working_dir
300
+
301
+ data = self._post(f"/api/v1/sessions/{session_id}/run", body)
302
+ return self._parse_run(data)
303
+
304
+ def run_async(
305
+ self,
306
+ session_id: str,
307
+ *,
308
+ command: str,
309
+ args: list[str] | None = None,
310
+ env: dict[str, str] | None = None,
311
+ working_dir: str = "",
312
+ timeout_seconds: int = 30,
313
+ callback_url: str = "",
314
+ ) -> AsyncRunResult:
315
+ """Submit a command for non-blocking (async) execution.
316
+
317
+ Returns immediately with the pending run's ID. Poll get_run() to check
318
+ for completion, or supply a callback_url to receive a webhook when done.
319
+ """
320
+ body: dict[str, Any] = {
321
+ "command": command,
322
+ "args": args or [],
323
+ "timeout_seconds": timeout_seconds,
324
+ }
325
+ if env:
326
+ body["env"] = env
327
+ if working_dir:
328
+ body["working_dir"] = working_dir
329
+ if callback_url:
330
+ body["callback_url"] = callback_url
331
+
332
+ data = self._post(f"/api/v1/sessions/{session_id}/run/async", body)
333
+ return AsyncRunResult(
334
+ run_id=data.get("run_id", ""),
335
+ status=data.get("status", "pending"),
336
+ message=data.get("message", ""),
337
+ )
338
+
339
+ def get_run(self, run_id: str) -> Run:
340
+ """Get a run by ID."""
341
+ return self._parse_run(self._get(f"/api/v1/runs/{run_id}"))
342
+
343
+ def list_runs(self, session_id: str) -> list[Run]:
344
+ """List all runs for a session."""
345
+ data = self._get(f"/api/v1/sessions/{session_id}/runs")
346
+ return [self._parse_run(r) for r in data.get("runs", [])]
347
+
348
+ # --- Session stats and logs ---
349
+
350
+ def get_session_stats(self, session_id: str) -> SessionStats:
351
+ """Return live CPU and memory usage for a running session."""
352
+ data = self._get(f"/api/v1/sessions/{session_id}/stats")
353
+ return SessionStats(
354
+ session_id=session_id,
355
+ cpu_percent=float(data.get("cpu_percent", 0)),
356
+ memory_usage_mb=float(data.get("memory_usage_mb", 0)),
357
+ memory_limit_mb=int(data.get("memory_limit_mb", 0)),
358
+ pids=int(data.get("pids", 0)),
359
+ )
360
+
361
+ def get_session_logs(self, session_id: str, *, tail: int = 100) -> str:
362
+ """Return the container stdout+stderr stream as a single string.
363
+
364
+ Args:
365
+ session_id: target session.
366
+ tail: number of lines to return from the end of the log (default 100).
367
+ """
368
+ data = self._get(f"/api/v1/sessions/{session_id}/logs?tail={tail}")
369
+ return data.get("logs", "")
370
+
371
+ # --- Docker image management ---
372
+
373
+ def list_images(self) -> list[Image]:
374
+ """List Docker images available on the VaultRun host."""
375
+ data = self._get("/api/v1/images")
376
+ return [
377
+ Image(
378
+ id=img["id"],
379
+ tags=img.get("tags", []),
380
+ size_bytes=img.get("size_bytes", 0),
381
+ created_at=img.get("created_at", ""),
382
+ )
383
+ for img in data.get("images", [])
384
+ ]
385
+
386
+ def pull_image(self, image: str) -> PullStatus:
387
+ """Pull a Docker image on the VaultRun host.
388
+
389
+ This is an async operation on the server side; the response reflects
390
+ whether the pull was accepted, not that it has completed.
391
+
392
+ Args:
393
+ image: Docker image reference, e.g. ``"python:3.12-slim"``.
394
+ """
395
+ data = self._post("/api/v1/images/pull", {"image": image})
396
+ return PullStatus(
397
+ image=data.get("image", image),
398
+ status=data.get("status", ""),
399
+ message=data.get("message", ""),
400
+ )
401
+
402
+ # --- Files ---
403
+
404
+ def upload_file(
405
+ self,
406
+ session_id: str,
407
+ remote_path: str,
408
+ content: IO[bytes] | bytes | str | Path,
409
+ ) -> File:
410
+ """Upload a file to a session workspace."""
411
+ if isinstance(content, (str, Path)):
412
+ content = open(content, "rb")
413
+ if isinstance(content, bytes):
414
+ content = io.BytesIO(content)
415
+
416
+ files = {"file": (Path(remote_path).name, content)}
417
+ data_fields = {"path": remote_path}
418
+
419
+ resp = self._session.post(
420
+ self.base_url + f"/api/v1/sessions/{session_id}/files",
421
+ files=files,
422
+ data=data_fields,
423
+ timeout=self._timeout,
424
+ )
425
+ self._raise_for_status(resp)
426
+ return self._parse_file(resp.json())
427
+
428
+ def download_file(self, session_id: str, remote_path: str) -> bytes:
429
+ """Download a single file from a session workspace."""
430
+ clean = remote_path.lstrip("/")
431
+ resp = self._session.get(
432
+ self.base_url + f"/api/v1/sessions/{session_id}/files/{clean}",
433
+ timeout=self._timeout,
434
+ )
435
+ self._raise_for_status(resp)
436
+ return resp.content
437
+
438
+ def download_workspace(self, session_id: str) -> bytes:
439
+ """Download the entire session workspace as a ZIP archive."""
440
+ resp = self._session.get(
441
+ self.base_url + f"/api/v1/sessions/{session_id}/workspace.zip",
442
+ timeout=0, # no timeout — workspace may be large
443
+ )
444
+ self._raise_for_status(resp)
445
+ return resp.content
446
+
447
+ def list_files(self, session_id: str) -> list[File]:
448
+ """List files in a session workspace."""
449
+ data = self._get(f"/api/v1/sessions/{session_id}/files")
450
+ return [self._parse_file(f) for f in data.get("files", [])]
451
+
452
+ # --- API key management ---
453
+
454
+ def list_keys(self) -> list[APIKey]:
455
+ """List all API keys (does not reveal plaintext keys)."""
456
+ data = self._get("/api/v1/keys")
457
+ return [self._parse_api_key(k) for k in data.get("api_keys", [])]
458
+
459
+ def create_key(
460
+ self,
461
+ name: str,
462
+ *,
463
+ expires_at: Optional[datetime] = None,
464
+ ) -> CreatedKey:
465
+ """Create a new API key. The plaintext key is only available in the returned object."""
466
+ body: dict[str, Any] = {"name": name}
467
+ if expires_at is not None:
468
+ if expires_at.tzinfo is None:
469
+ expires_at = expires_at.replace(tzinfo=timezone.utc)
470
+ body["expires_at"] = expires_at.isoformat()
471
+ data = self._post("/api/v1/keys", body)
472
+ return self._parse_created_key(data)
473
+
474
+ def revoke_key(self, key_id: str) -> None:
475
+ """Revoke an API key by ID."""
476
+ self._delete(f"/api/v1/keys/{key_id}")
477
+
478
+ # --- Audit ---
479
+
480
+ def list_audit_logs(
481
+ self,
482
+ session_id: Optional[str] = None,
483
+ limit: int = 50,
484
+ offset: int = 0,
485
+ ) -> list["AuditLog"]:
486
+ """Return audit log entries for the current actor.
487
+
488
+ Master key holders see all actors' entries; regular keys see only their
489
+ own sessions' entries. Entries are ordered newest-first.
490
+
491
+ Args:
492
+ session_id: restrict results to a single session (optional).
493
+ limit: maximum entries to return (default 50).
494
+ offset: pagination offset.
495
+
496
+ Returns:
497
+ List of :class:`AuditLog` entries.
498
+ """
499
+ params: list[str] = []
500
+ if session_id:
501
+ params.append(f"session_id={session_id}")
502
+ if limit != 50:
503
+ params.append(f"limit={limit}")
504
+ if offset:
505
+ params.append(f"offset={offset}")
506
+ path = "/api/v1/audit"
507
+ if params:
508
+ path += "?" + "&".join(params)
509
+ data = self._get(path)
510
+ return [self._parse_audit_log(e) for e in data.get("audit_logs", [])]
511
+
512
+ # --- Streaming ---
513
+
514
+ def stream(
515
+ self,
516
+ session_id: str,
517
+ command: str,
518
+ args: Optional[list[str]] = None,
519
+ env: Optional[dict[str, str]] = None,
520
+ working_dir: Optional[str] = None,
521
+ timeout_seconds: int = 30,
522
+ stdout: Optional[IO[str]] = None,
523
+ stderr: Optional[IO[str]] = None,
524
+ ) -> "StreamResult":
525
+ """Execute a command via SSE streaming, writing output chunks as they arrive.
526
+
527
+ Unlike :meth:`run`, which buffers all output and returns when the command
528
+ finishes, ``stream`` writes stdout/stderr to the provided file-like objects
529
+ as each chunk arrives. This is useful for long-running commands where you
530
+ want to observe progress in real time.
531
+
532
+ Args:
533
+ session_id: target session.
534
+ command: executable name (no shell — no metacharacters).
535
+ args: positional arguments.
536
+ env: extra environment variables injected into the exec.
537
+ working_dir: working directory inside the container.
538
+ timeout_seconds: server-side execution timeout.
539
+ stdout: writable text stream for stdout chunks (default: discard).
540
+ stderr: writable text stream for stderr chunks (default: discard).
541
+
542
+ Returns:
543
+ :class:`StreamResult` with the final status, exit code, and duration.
544
+
545
+ Example::
546
+
547
+ import sys
548
+ result = client.stream(
549
+ session.id, "python", args=["script.py"],
550
+ stdout=sys.stdout, stderr=sys.stderr,
551
+ )
552
+ print(f"exit code: {result.exit_code}")
553
+ """
554
+ body: dict[str, Any] = {
555
+ "command": command,
556
+ "args": args or [],
557
+ "timeout_seconds": timeout_seconds,
558
+ }
559
+ if env:
560
+ body["env"] = env
561
+ if working_dir:
562
+ body["working_dir"] = working_dir
563
+
564
+ resp = self._session.post(
565
+ self.base_url + f"/api/v1/sessions/{session_id}/run/stream",
566
+ json=body,
567
+ headers={"Accept": "text/event-stream"},
568
+ stream=True,
569
+ timeout=None, # caller controls duration via context / timeout_seconds
570
+ )
571
+ self._raise_for_status(resp)
572
+
573
+ final: dict[str, Any] = {}
574
+ for line in resp.iter_lines():
575
+ if not line:
576
+ continue
577
+ if isinstance(line, bytes):
578
+ line = line.decode()
579
+ if not line.startswith("data:"):
580
+ continue
581
+ payload = line[5:].strip()
582
+ if not payload:
583
+ continue
584
+ try:
585
+ event = json.loads(payload)
586
+ except ValueError:
587
+ continue
588
+ etype = event.get("type", "")
589
+ if etype == "stdout" and stdout is not None:
590
+ stdout.write(event.get("data", ""))
591
+ elif etype == "stderr" and stderr is not None:
592
+ stderr.write(event.get("data", ""))
593
+ elif etype == "done":
594
+ final = event
595
+ break
596
+
597
+ return StreamResult(
598
+ run_id=final.get("run_id", ""),
599
+ status=final.get("status", "unknown"),
600
+ exit_code=final.get("exit_code"),
601
+ duration_ms=final.get("duration_ms"),
602
+ )
603
+
604
+ # --- Organizations & RBAC ---
605
+
606
+ def create_org(self, name: str, slug: Optional[str] = None) -> Organization:
607
+ """Create a new organization. Requires master key.
608
+
609
+ Args:
610
+ name: human-readable org name.
611
+ slug: URL-safe identifier; auto-generated from name if omitted.
612
+
613
+ Returns:
614
+ :class:`Organization`
615
+ """
616
+ body: dict[str, Any] = {"name": name}
617
+ if slug:
618
+ body["slug"] = slug
619
+ d = self._post("/api/v1/orgs", body)
620
+ return self._parse_org(d)
621
+
622
+ def list_orgs(self) -> list[Organization]:
623
+ """List all organizations. Requires master key."""
624
+ d = self._get("/api/v1/orgs")
625
+ return [self._parse_org(o) for o in d.get("orgs", [])]
626
+
627
+ def get_org(self, org_id: str) -> Organization:
628
+ """Fetch a single org by ID. Accessible to org members."""
629
+ d = self._get(f"/api/v1/orgs/{org_id}")
630
+ return self._parse_org(d)
631
+
632
+ def delete_org(self, org_id: str) -> None:
633
+ """Delete an org and all its members. Requires master key."""
634
+ self._delete(f"/api/v1/orgs/{org_id}")
635
+
636
+ def add_org_member(
637
+ self,
638
+ org_id: str,
639
+ principal: str,
640
+ role: str = "executor",
641
+ ) -> OrgMember:
642
+ """Add or update an org member.
643
+
644
+ Args:
645
+ org_id: target organization ID.
646
+ principal: API key name (the actor string).
647
+ role: one of ``"viewer"``, ``"executor"``, or ``"admin"``.
648
+
649
+ Requires master key or org admin role.
650
+ """
651
+ body: dict[str, Any] = {"principal": principal, "role": role}
652
+ d = self._post(f"/api/v1/orgs/{org_id}/members", body)
653
+ return self._parse_org_member(d)
654
+
655
+ def list_org_members(self, org_id: str) -> list[OrgMember]:
656
+ """List all members of an org. Accessible to org members."""
657
+ d = self._get(f"/api/v1/orgs/{org_id}/members")
658
+ return [self._parse_org_member(m) for m in d.get("members", [])]
659
+
660
+ def remove_org_member(self, org_id: str, principal: str) -> None:
661
+ """Remove a principal from an org. Requires master key or org admin."""
662
+ self._delete(f"/api/v1/orgs/{org_id}/members/{principal}")
663
+
664
+ def list_org_sessions(self, org_id: str) -> list[Session]:
665
+ """List active sessions that belong to the org.
666
+
667
+ The caller must be an org member; the server filters by role.
668
+ """
669
+ d = self._get(f"/api/v1/orgs/{org_id}/sessions")
670
+ return [self._parse_session(s) for s in d.get("sessions", [])]
671
+
672
+ # --- Snapshots ---
673
+
674
+ def create_snapshot(self, session_id: str, *, name: str) -> Snapshot:
675
+ """Create a snapshot archive of a session's workspace."""
676
+ data = self._post(f"/api/v1/sessions/{session_id}/snapshots", {"name": name})
677
+ return Snapshot(**{k: v for k, v in data.items() if k in Snapshot.__dataclass_fields__})
678
+
679
+ def list_snapshots(self, session_id: str) -> list[Snapshot]:
680
+ """List all snapshots for a session."""
681
+ data = self._get(f"/api/v1/sessions/{session_id}/snapshots")
682
+ return [Snapshot(**{k: v for k, v in s.items() if k in Snapshot.__dataclass_fields__})
683
+ for s in data.get("snapshots", [])]
684
+
685
+ def download_snapshot(self, snapshot_id: str) -> bytes:
686
+ """Download a snapshot archive as bytes."""
687
+ resp = self._session.get(
688
+ f"{self.base_url}/api/v1/snapshots/{snapshot_id}/download",
689
+ timeout=self._timeout,
690
+ )
691
+ self._raise_for_status(resp)
692
+ return resp.content
693
+
694
+ def delete_snapshot(self, snapshot_id: str) -> None:
695
+ """Delete a snapshot."""
696
+ self._delete(f"/api/v1/snapshots/{snapshot_id}")
697
+
698
+ # --- Artifacts ---
699
+
700
+ def promote_artifact(
701
+ self,
702
+ session_id: str,
703
+ path: str,
704
+ *,
705
+ name: str = "",
706
+ ) -> SharedArtifact:
707
+ """Promote a workspace file to the shared artifact registry."""
708
+ body: dict[str, Any] = {"path": path}
709
+ if name:
710
+ body["name"] = name
711
+ data = self._post(f"/api/v1/sessions/{session_id}/artifacts", body)
712
+ return SharedArtifact(**{k: v for k, v in data.items() if k in SharedArtifact.__dataclass_fields__})
713
+
714
+ def list_artifacts(self) -> list[SharedArtifact]:
715
+ """List shared artifacts visible to the caller."""
716
+ data = self._get("/api/v1/artifacts")
717
+ return [SharedArtifact(**{k: v for k, v in a.items() if k in SharedArtifact.__dataclass_fields__})
718
+ for a in data.get("artifacts", [])]
719
+
720
+ def download_artifact(self, artifact_id: str) -> bytes:
721
+ """Download a shared artifact as bytes."""
722
+ resp = self._session.get(
723
+ f"{self.base_url}/api/v1/artifacts/{artifact_id}/download",
724
+ timeout=self._timeout,
725
+ )
726
+ self._raise_for_status(resp)
727
+ return resp.content
728
+
729
+ def delete_artifact(self, artifact_id: str) -> None:
730
+ """Delete a shared artifact."""
731
+ self._delete(f"/api/v1/artifacts/{artifact_id}")
732
+
733
+ # --- Webhook signature verification ---
734
+
735
+ @staticmethod
736
+ def verify_webhook_signature(
737
+ payload: bytes,
738
+ signature_header: str,
739
+ secret: str,
740
+ ) -> bool:
741
+ """Verify the X-VaultRun-Signature header on a callback POST.
742
+
743
+ Args:
744
+ payload: raw request body bytes
745
+ signature_header: value of the X-VaultRun-Signature header
746
+ secret: the WEBHOOK_SECRET configured on the server
747
+
748
+ Returns True when the signature is valid, False otherwise.
749
+
750
+ Example (Flask)::
751
+
752
+ @app.route("/webhook", methods=["POST"])
753
+ def webhook():
754
+ if not Client.verify_webhook_signature(
755
+ request.data,
756
+ request.headers.get("X-VaultRun-Signature", ""),
757
+ os.environ["WEBHOOK_SECRET"],
758
+ ):
759
+ abort(401)
760
+ ...
761
+ """
762
+ if not signature_header.startswith("sha256="):
763
+ return False
764
+ expected = signature_header[7:]
765
+ mac = hmac.new(secret.encode(), payload, hashlib.sha256)
766
+ computed = mac.hexdigest()
767
+ return hmac.compare_digest(computed, expected)
768
+
769
+ # --- Internal helpers ---
770
+
771
+ def _get(self, path: str) -> dict:
772
+ resp = self._session.get(self.base_url + path, timeout=self._timeout)
773
+ self._raise_for_status(resp)
774
+ return resp.json()
775
+
776
+ def _post(self, path: str, body: dict) -> dict:
777
+ resp = self._session.post(self.base_url + path, json=body, timeout=self._timeout)
778
+ self._raise_for_status(resp)
779
+ return resp.json()
780
+
781
+ def _patch(self, path: str, body: dict) -> dict:
782
+ resp = self._session.patch(self.base_url + path, json=body, timeout=self._timeout)
783
+ self._raise_for_status(resp)
784
+ return resp.json()
785
+
786
+ def _delete(self, path: str) -> None:
787
+ resp = self._session.delete(self.base_url + path, timeout=self._timeout)
788
+ self._raise_for_status(resp)
789
+
790
+ @staticmethod
791
+ def _raise_for_status(resp: requests.Response) -> None:
792
+ if resp.status_code >= 400:
793
+ try:
794
+ msg = resp.json().get("error", resp.text)
795
+ except Exception:
796
+ msg = resp.text
797
+ raise VaultRunError(resp.status_code, msg)
798
+
799
+ @staticmethod
800
+ def _parse_api_key(d: dict) -> APIKey:
801
+ return APIKey(
802
+ id=d["id"],
803
+ name=d["name"],
804
+ prefix=d["prefix"],
805
+ active=d["active"],
806
+ created_at=d["created_at"],
807
+ last_used_at=d.get("last_used_at"),
808
+ expires_at=d.get("expires_at"),
809
+ )
810
+
811
+ @staticmethod
812
+ def _parse_created_key(d: dict) -> CreatedKey:
813
+ return CreatedKey(
814
+ id=d["id"],
815
+ name=d["name"],
816
+ prefix=d["prefix"],
817
+ active=d["active"],
818
+ created_at=d["created_at"],
819
+ last_used_at=d.get("last_used_at"),
820
+ expires_at=d.get("expires_at"),
821
+ key=d["key"],
822
+ )
823
+
824
+ @staticmethod
825
+ def _parse_session(d: dict) -> Session:
826
+ raw_labels = d.get("labels") or {}
827
+ labels = {str(k): str(v) for k, v in raw_labels.items()}
828
+ return Session(
829
+ id=d["id"],
830
+ name=d.get("name"),
831
+ image=d["image"],
832
+ status=d["status"],
833
+ container_id=d.get("container_id"),
834
+ network_enabled=d["network_enabled"],
835
+ cpu_limit=d["cpu_limit"],
836
+ memory_limit_mb=d["memory_limit_mb"],
837
+ timeout_seconds=d["timeout_seconds"],
838
+ labels=labels,
839
+ created_at=d["created_at"],
840
+ stopped_at=d.get("stopped_at"),
841
+ )
842
+
843
+ @staticmethod
844
+ def _parse_run(d: dict) -> Run:
845
+ return Run(
846
+ id=d["id"],
847
+ session_id=d["session_id"],
848
+ command=d["command"],
849
+ args=d.get("args", []),
850
+ status=d["status"],
851
+ exit_code=d.get("exit_code"),
852
+ stdout=d.get("stdout"),
853
+ stderr=d.get("stderr"),
854
+ duration_ms=d.get("duration_ms"),
855
+ output_truncated=d.get("output_truncated", False),
856
+ timeout_seconds=d["timeout_seconds"],
857
+ created_at=d["created_at"],
858
+ started_at=d.get("started_at"),
859
+ finished_at=d.get("finished_at"),
860
+ )
861
+
862
+ @staticmethod
863
+ def _parse_file(d: dict) -> File:
864
+ return File(
865
+ id=d["id"],
866
+ session_id=d["session_id"],
867
+ path=d["path"],
868
+ size_bytes=d["size_bytes"],
869
+ content_type=d["content_type"],
870
+ created_at=d["created_at"],
871
+ )
872
+
873
+ @staticmethod
874
+ def _parse_audit_log(d: dict) -> AuditLog:
875
+ return AuditLog(
876
+ id=d["id"],
877
+ actor=d["actor"],
878
+ action=d["action"],
879
+ timestamp=d["timestamp"],
880
+ session_id=d.get("session_id"),
881
+ run_id=d.get("run_id"),
882
+ metadata=d.get("metadata"),
883
+ )
884
+
885
+ @staticmethod
886
+ def _parse_org(d: dict) -> Organization:
887
+ return Organization(
888
+ id=d["id"],
889
+ name=d["name"],
890
+ slug=d["slug"],
891
+ created_at=d["created_at"],
892
+ updated_at=d["updated_at"],
893
+ )
894
+
895
+ @staticmethod
896
+ def _parse_org_member(d: dict) -> OrgMember:
897
+ return OrgMember(
898
+ org_id=d["org_id"],
899
+ principal=d["principal"],
900
+ role=d["role"],
901
+ created_at=d["created_at"],
902
+ )
@@ -0,0 +1,77 @@
1
+ Metadata-Version: 2.4
2
+ Name: vaultrun-sdk
3
+ Version: 0.2.0
4
+ Summary: Python SDK for the VaultRun secure AI agent sandbox runtime
5
+ License: Apache-2.0
6
+ Project-URL: Homepage, https://vaultrun.dev
7
+ Project-URL: Repository, https://github.com/nickvd7/vaultrun
8
+ Project-URL: Issues, https://github.com/nickvd7/vaultrun/issues
9
+ Project-URL: Changelog, https://github.com/nickvd7/vaultrun/blob/main/CHANGELOG.md
10
+ Keywords: ai-agents,sandbox,mcp,docker,security,self-hosted
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: Apache Software License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
+ Classifier: Topic :: Security
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ Requires-Dist: requests>=2.31.0
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest>=8; extra == "dev"
25
+ Requires-Dist: responses>=0.25; extra == "dev"
26
+
27
+ # vaultrun-sdk
28
+
29
+ Python SDK for [VaultRun](https://vaultrun.dev) — the self-hosted secure runtime for AI agents.
30
+
31
+ VaultRun lets AI agents safely execute code, query databases, call cloud APIs, and manage
32
+ files inside isolated Docker sandboxes running on your own infrastructure. This package is
33
+ the typed Python client for the VaultRun REST API.
34
+
35
+ ## Install
36
+
37
+ ```bash
38
+ pip install vaultrun-sdk
39
+ ```
40
+
41
+ ## Quickstart
42
+
43
+ ```python
44
+ from sandbox_sdk import Client
45
+
46
+ client = Client("http://localhost:8080", api_key="vr_...")
47
+
48
+ session = client.create_session(image="python:3.12-slim", memory_limit_mb=256)
49
+ client.upload_file(session.id, "script.py", open("script.py", "rb"))
50
+
51
+ result = client.run(session.id, command="python", args=["script.py"])
52
+ print(result.stdout)
53
+
54
+ client.delete_session(session.id)
55
+ ```
56
+
57
+ ## Features
58
+
59
+ - Sessions: create, list, get, delete isolated sandbox sessions
60
+ - Runs: execute commands, stream output, fetch run details and logs
61
+ - Files: upload, list, and download workspace files
62
+ - Keys, organizations, audit logs, snapshots, artifacts, images, and session stats
63
+
64
+ ## Requirements
65
+
66
+ - Python 3.10+
67
+ - A running [VaultRun](https://github.com/nickvd7/vaultrun) instance and an API key (`vr_...`)
68
+
69
+ ## Links
70
+
71
+ - Website: https://vaultrun.dev
72
+ - Source & docs: https://github.com/nickvd7/vaultrun
73
+ - Issues: https://github.com/nickvd7/vaultrun/issues
74
+
75
+ ## License
76
+
77
+ Apache 2.0
@@ -0,0 +1,6 @@
1
+ sandbox_sdk/__init__.py,sha256=7QVYUm3uT10HJXTJzmmMC54z4nbkijbWRnG2vNmo4VM,674
2
+ sandbox_sdk/client.py,sha256=CW6uqOWzND8_XXUDfVd1JD-Sibi_FPYE4gockth7VZ0,28417
3
+ vaultrun_sdk-0.2.0.dist-info/METADATA,sha256=YRW4XEEm2neS1qkv2EULeiGBdcoDcZzRzcJFCckxaQc,2492
4
+ vaultrun_sdk-0.2.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
5
+ vaultrun_sdk-0.2.0.dist-info/top_level.txt,sha256=ykxgSok0elqp8YNIoo8NP6xBZKrC3bKbjIScaN4-l_o,12
6
+ vaultrun_sdk-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ sandbox_sdk