agvisor 0.1.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.
agvisor/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Throwaway gVisor (runsc) sandboxes for coding agents, driven through the docker CLI."""
2
+ from .limits import Limits
3
+ from .sandbox import Sandbox, create_sandbox, list_sandboxes
4
+
5
+ __all__ = ["Limits", "Sandbox", "create_sandbox", "list_sandboxes"]
agvisor/desktop.py ADDED
@@ -0,0 +1,41 @@
1
+ """GUI sandbox specifics: the GNOME-over-RDP image details and the token for gateway/gateway.js."""
2
+ import base64
3
+ import hmac
4
+ import json
5
+ import os
6
+ import time
7
+
8
+ GUI_IMAGE = "systemd-debian-gnome" # built from the gVisor repo, see README
9
+ GUI_USER = "gnome"
10
+ GUI_HOME = f"/home/{GUI_USER}" # persisted for named gui sandboxes (assumed home of GUI_USER: verify on the image)
11
+ RDP_PORT = 3389
12
+
13
+
14
+ def make_token(key: bytes, rdp_port: int, password: str, ttl: int = 300) -> str:
15
+ """Token for <agvisor-desktop> (needs `pip install agvisor[web]`). Valid for `ttl` seconds to *open* a session.
16
+
17
+ `key` is the 32-byte secret gateway/gateway.js also has. guacamole-lite can only decrypt AES-CBC, which does
18
+ not detect tampering: a client could flip IV bits and turn `{"expiration": 1...` into `9...`, making an
19
+ expired token last for centuries. So the token is encrypt-then-MAC: gateway.js checks the HMAC first.
20
+ """
21
+ from cryptography.hazmat.primitives import padding
22
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
23
+
24
+ if len(key) != 32:
25
+ raise ValueError("key must be exactly 32 bytes (same as AGVISOR_KEY, hex-decoded)")
26
+ # one secret, two purposes: derive separate AES and HMAC keys (gateway.js derives them the same way)
27
+ enc_key, mac_key = (hmac.digest(key, label, "sha256") for label in (b"agvisor-enc", b"agvisor-mac"))
28
+
29
+ msg = json.dumps({
30
+ "expiration": int((time.time() + ttl) * 1000), # checked by gateway.js
31
+ "connection": {"type": "rdp", "settings": {
32
+ "hostname": "127.0.0.1", "port": str(rdp_port), # guacd runs on the docker host
33
+ "username": GUI_USER, "password": password,
34
+ "security": "any", "ignore-cert": True, "resize-method": "display-update"}},
35
+ }).encode()
36
+ iv, pad = os.urandom(16), padding.PKCS7(128).padder()
37
+ enc = Cipher(algorithms.AES(enc_key), modes.CBC(iv)).encryptor()
38
+ ct = enc.update(pad.update(msg) + pad.finalize()) + enc.finalize()
39
+ mac = hmac.HMAC(mac_key, iv + ct, "sha256").digest() # over the exact bytes gateway.js will verify
40
+ b64 = lambda b: base64.b64encode(b).decode() # guacamole-lite token format, plus `mac` (it ignores it)
41
+ return b64(json.dumps({"iv": b64(iv), "value": b64(ct), "mac": b64(mac)}).encode())
agvisor/limits.py ADDED
@@ -0,0 +1,32 @@
1
+ import re
2
+ from dataclasses import dataclass
3
+
4
+
5
+ @dataclass(frozen=True)
6
+ class Limits:
7
+ """Resource limits, enforced by gVisor/cgroups on the machine hosting the sandbox.
8
+
9
+ cpus CPU cores, fractions allowed (0.5).
10
+ memory Hard RAM cap in docker syntax ("512m", "4g"). Swap is disabled.
11
+ pids Max processes/threads (stops fork bombs).
12
+ shm /dev/shm size ("1g"). Default: docker's 64m, or 1g for gui.
13
+ disk Hard cap on the root filesystem: everything the sandbox writes outside /workspace and /tmp ("5g").
14
+ A whole number + optional k/m/g/t (no decimals, no "gb"). It does NOT cover the /workspace volume of a
15
+ named sandbox (a plain host directory: unbounded), and /tmp is RAM, counted in `memory`.
16
+ """
17
+
18
+ cpus: float = 2.0
19
+ memory: str = "2g"
20
+ pids: int = 512
21
+ shm: str | None = None
22
+ disk: str = "5g"
23
+
24
+ def __post_init__(self):
25
+ # docker reads 0 as "unlimited": 0 must not silently remove a cap the caller asked for
26
+ mem = re.match(r"\d+(\.\d+)?", str(self.memory))
27
+ if not (self.cpus > 0 and self.pids > 0 and mem and float(mem.group()) > 0):
28
+ raise ValueError(f"cpus, memory and pids must be > 0, got {self}")
29
+ # runsc reads "0" as unlimited and rejects "1.5g"/"2gb" only when the container starts: fail here instead
30
+ disk = re.fullmatch(r"(\d+)[kmgtKMGT]?", str(self.disk))
31
+ if not (disk and int(disk[1]) > 0):
32
+ raise ValueError(f"disk must be a whole number > 0 plus optional k/m/g/t (e.g. '5g'), got {self.disk!r}")
agvisor/sandbox.py ADDED
@@ -0,0 +1,253 @@
1
+ import codecs
2
+ import collections
3
+ import os
4
+ import re
5
+ import secrets
6
+ import subprocess
7
+ import uuid
8
+
9
+ from .desktop import GUI_HOME, GUI_IMAGE, GUI_USER, RDP_PORT, make_token
10
+ from .limits import Limits
11
+
12
+ _KEEP = 100_000 # exec(on_output=) keeps only this many trailing characters: the rest already went to on_output
13
+ _MARK = "/tmp/.agvisor_active" # idle_ttl: every exec() touches it; PID 1 exits once it is older than idle_ttl
14
+ # gui: RDP checks GNOME Remote Desktop's own credentials file, which the image bakes as gnome:gnome, NOT the unix
15
+ # password. Run as GUI_USER with the file on stdin and its path as $1: store ours, wait for the desktop to boot
16
+ # (up to 120 s), restart the RDP service (it reads the file only at startup), wait until it listens on 3389 (0D3D).
17
+ _RDP_CREDS = f"{GUI_HOME}/.local/share/gnome-remote-desktop/credentials.ini"
18
+ _RDP_SETUP = r"""set -e
19
+ umask 077
20
+ cat > "$1"
21
+ export XDG_RUNTIME_DIR=/run/user/$(id -u)
22
+ S=gnome-remote-desktop-headless.service
23
+ wait_for() { n=0; until "$@"; do n=$((n+1)); [ $n -le 120 ] || { echo "desktop not ready after 120s: $*" >&2; exit 1; }; sleep 1; done; }
24
+ wait_for systemctl --user is-active --quiet $S
25
+ systemctl --user restart $S
26
+ wait_for grep -qE ':0D3D [0-9A-F:]+ 0A' /proc/net/tcp /proc/net/tcp6
27
+ """
28
+ _RUNC = ("runc", "io.containerd.runc.v2") # --privileged (gui) is only simulated under gVisor, real under these
29
+
30
+
31
+ class Sandbox:
32
+ """host=None -> local docker; host="ssh://user@vm" -> docker on a remote VM/server."""
33
+
34
+ def __init__(self, image=None, *, gui=False, limits=Limits(), network="bridge", lifetime=None,
35
+ idle_ttl=None, name=None, host=None, runtime="runsc"):
36
+ self.gui, self.limits, self.network, self.lifetime, self.host = gui, limits, network, lifetime, host
37
+ self.idle_ttl, self.runtime = idle_ttl, runtime
38
+ # strict like `name`: it ends up in --runtime=. And never plain runc for a gui: it is run --privileged
39
+ if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]*", runtime) or (gui and runtime in _RUNC):
40
+ raise ValueError(f"invalid runtime {runtime!r}: must be a gVisor (runsc) runtime registered in docker")
41
+ self.image = image or (GUI_IMAGE if gui else "ubuntu:24.04")
42
+ if self.image.startswith("-"): # `docker run ... IMAGE` would parse it as a flag, e.g. --privileged
43
+ raise ValueError(f"invalid image name: {self.image!r}")
44
+ if lifetime is not None and (gui or lifetime <= 0):
45
+ raise ValueError("lifetime must be > 0 seconds and is only supported for headless sandboxes")
46
+ if idle_ttl is not None and (gui or idle_ttl <= 0):
47
+ raise ValueError("idle_ttl must be > 0 seconds and is only supported for headless sandboxes")
48
+ # strict: `name` ends up in --name and --volume, where a ',' or ':' would inject mount options
49
+ if name is not None and not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,62}", name):
50
+ raise ValueError(f"invalid name {name!r}: letters, digits, '_', '.', '-' only (max 63)")
51
+ self.persistent = name is not None
52
+ self.name = f"agvisor-{name or uuid.uuid4().hex[:8]}" # also the name of the data volume
53
+ self.data_dir = (GUI_HOME if gui else "/workspace") if self.persistent else None # survives stop()
54
+ self.rdp_port = self.password = None # set by start() when gui; user is GUI_USER
55
+ self._running = False
56
+
57
+ def _docker(self, *args, input=None, check=True):
58
+ cmd = ["docker", *(["-H", self.host] if self.host else []), *args]
59
+ r = subprocess.run(cmd, input=input, capture_output=True, text=True)
60
+ if check and r.returncode:
61
+ raise RuntimeError(r.stderr.strip())
62
+ return r
63
+
64
+ def _stream(self, *args, on_output):
65
+ """Like _docker, but calls on_output(text) as each chunk arrives. stderr is merged into stdout."""
66
+ cmd = ["docker", *(["-H", self.host] if self.host else []), *args]
67
+ p = subprocess.Popen(cmd, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
68
+ dec, tail, kept = codecs.getincrementaldecoder("utf-8")("replace"), collections.deque(), 0 # a chunk can end mid-character
69
+ while chunk := os.read(p.stdout.fileno(), 4096):
70
+ if text := dec.decode(chunk):
71
+ tail.append(text)
72
+ kept += len(text)
73
+ while kept - len(tail[0]) >= _KEEP: # a runaway `yes` must not fill our memory
74
+ kept -= len(tail.popleft())
75
+ on_output(text)
76
+ p.wait()
77
+ return subprocess.CompletedProcess(cmd, p.returncode, "".join(tail), "")
78
+
79
+ def run_args(self):
80
+ l = self.limits
81
+ a = ["run", "-d", f"--runtime={self.runtime}", f"--name={self.name}",
82
+ f"--cpus={l.cpus}", f"--memory={l.memory}", f"--memory-swap={l.memory}",
83
+ f"--pids-limit={l.pids}", f"--network={self.network}",
84
+ # gVisor caps the rootfs overlay (a file on the host disk): writes past it get ENOSPC. Per-container,
85
+ # no daemon setting needed. `root:` only: `all:` would also overlay /workspace and stop it persisting.
86
+ f"--annotation=dev.gvisor.flag.overlay2=root:self,size={l.disk}"]
87
+ if self.lifetime or self.idle_ttl: # PID 1 ends on its own: the container exits, then --rm deletes it, even if we crash
88
+ a.append("--rm")
89
+ a.append("--label=agvisor=1") # what list_sandboxes() finds
90
+ if self.persistent:
91
+ a.append(f"--label=agvisor.name={self.name.removeprefix('agvisor-')}")
92
+ if self.data_dir: # a docker volume is a host directory: it is outside gVisor's discarded rootfs overlay
93
+ a.append(f"--volume={self.name}:{self.data_dir}")
94
+ if not self.gui:
95
+ a.append(f"--workdir={self.data_dir}") # relative paths in exec() land on persistent storage
96
+ if self.gui: # --privileged only grants simulated caps inside gVisor, not host access
97
+ a += ["--privileged", f"--shm-size={l.shm or '1g'}", f"--publish=127.0.0.1::{RDP_PORT}"]
98
+ elif l.shm:
99
+ a.append(f"--shm-size={l.shm}")
100
+ if not self.gui: # PID 1 is `sleep`, which never reaps orphans: they stay zombies and eat the pids limit
101
+ a.append("--init") # (gui: systemd is PID 1 and reaps)
102
+ return a + [self.image] + self._pid1()
103
+
104
+ def _pid1(self):
105
+ if self.gui:
106
+ return []
107
+ if not self.idle_ttl:
108
+ return ["sleep", str(self.lifetime or "infinity")]
109
+ # exits when the marker is older than idle_ttl (checked every 5s); a missing marker counts as expired.
110
+ # `timeout` adds lifetime as a hard cap on top.
111
+ watch = (f"touch {_MARK}; while [ $(( $(date +%s) - $(stat -c %Y {_MARK} 2>/dev/null || echo 0) )) "
112
+ f"-lt {self.idle_ttl} ]; do sleep 5; done")
113
+ return (["timeout", str(self.lifetime)] if self.lifetime else []) + ["sh", "-c", watch]
114
+
115
+ def _left_running(self):
116
+ """A named container an earlier session never stopped: resume it, processes and all."""
117
+ if not self.persistent:
118
+ return False
119
+ r = self._docker("inspect", "-f", "{{.State.Running}}", self.name, check=False)
120
+ return r.stdout.strip() == "true"
121
+
122
+ def start(self):
123
+ if self._running: # create_sandbox() already started it, then `with` calls start() again
124
+ return self
125
+ resumed = self._left_running() # limits/image args are ignored then: they were fixed at creation
126
+ if not resumed:
127
+ if self.persistent: # a stopped leftover would block the name; its data lives in the volume
128
+ self._docker("rm", "-f", self.name, check=False)
129
+ self._docker(*self.run_args())
130
+ self._running = True # the container exists from here, so stop() must clean up if setup fails
131
+ try:
132
+ if self.gui: # random host port + random password instead of the image's gnome:gnome
133
+ out = self._docker("port", self.name, f"{RDP_PORT}/tcp").stdout
134
+ self.rdp_port = int(out.splitlines()[0].rsplit(":", 1)[1])
135
+ self.password = secrets.token_urlsafe(12) # urlsafe: no quote that could break the file below
136
+ self._docker("exec", "-i", "-u", "root", self.name, "chpasswd",
137
+ input=f"{GUI_USER}:{self.password}\n") # sudo / console login
138
+ creds = f"[RDP]\ncredentials={{'username': <'{GUI_USER}'>, 'password': <'{self.password}'>}}\n"
139
+ self._docker("exec", "-i", "-u", GUI_USER, self.name, "sh", "-c", _RDP_SETUP, "sh", _RDP_CREDS,
140
+ input=creds) # RDP login; stdin keeps the password out of `ps`. Returns once RDP listens
141
+ except BaseException:
142
+ if resumed: # never destroy a container (and its processes) that we merely re-attached to
143
+ self._running = False
144
+ else:
145
+ self.stop()
146
+ raise
147
+ return self
148
+
149
+ def exec(self, cmd, *, input=None, timeout=120, on_output=None):
150
+ """Run a shell command inside. Returns CompletedProcess; returncode 124 = timed out.
151
+ `on_output(text)` is called with output as it is produced (stderr merged into stdout, which then holds
152
+ only the last ~100 KB; it must not raise, and can't be combined with `input`).
153
+ That needs GNU `timeout`, i.e. a Debian/Ubuntu-based image. Alpine's busybox returns 143 instead and kills
154
+ only the shell: the children of a compound command (`a; b`) keep running."""
155
+ if self.idle_ttl:
156
+ if timeout + 6 >= self.idle_ttl: # +5 = the kill grace below, +1 = the watchdog compares whole seconds
157
+ raise ValueError(f"timeout={timeout}s must stay below idle_ttl={self.idle_ttl}s, "
158
+ "or the sandbox could expire in the middle of the command")
159
+ # activity = an exec starting or ending (the trap also fires on `exit`)
160
+ cmd = f"trap 'touch {_MARK} 2>/dev/null' EXIT; touch {_MARK} 2>/dev/null; {cmd}"
161
+ argv = ["timeout", "-k", "5", str(timeout), "sh", "-c", cmd] # kills inside, not just the client
162
+ if on_output:
163
+ if input is not None:
164
+ raise ValueError("on_output can't be combined with input")
165
+ return self._stream("exec", "-i", self.name, *argv, on_output=on_output)
166
+ return self._docker("exec", "-i", self.name, *argv, input=input, check=False)
167
+
168
+ def desktop_token(self, key: bytes, ttl=300):
169
+ """Token for <agvisor-desktop>. Mint it server-side only: it carries the RDP password.
170
+ Replayable until `ttl` runs out."""
171
+ if self.rdp_port is None:
172
+ raise ValueError("start() a gui=True sandbox first")
173
+ return make_token(key, self.rdp_port, self.password, ttl)
174
+
175
+ def stop(self):
176
+ """Remove the container and free its CPU/RAM. A named sandbox keeps its data volume."""
177
+ self._docker("rm", "-f", self.name, check=False)
178
+ self._running = False
179
+
180
+ def delete(self):
181
+ """stop() and also erase the persistent data, so create_sandbox(name=...) starts from scratch."""
182
+ self.stop()
183
+ if self.persistent:
184
+ self._docker("volume", "rm", "-f", self.name, check=False)
185
+
186
+ def __enter__(self):
187
+ return self.start()
188
+
189
+ def __exit__(self, *_):
190
+ self.stop()
191
+
192
+
193
+ def list_sandboxes(host: str | None = None) -> list[Sandbox]:
194
+ """Every agvisor sandbox on that docker host, running or not, as handles: exec(), stop() and delete() work.
195
+ Anonymous ones (leaked by a crashed process) are included, so this is also how you find and kill them.
196
+ Don't start() a stopped handle: that makes a fresh default-image container."""
197
+ out = Sandbox(host=host)._docker("ps", "-a", "--filter", "label=agvisor",
198
+ "--format", '{{.Names}} {{.State}} {{.Label "agvisor.name"}}').stdout
199
+ boxes = []
200
+ for line in out.splitlines():
201
+ cname, state, *label = line.split() # an empty label leaves no third field
202
+ sb = Sandbox(name=label[0] if label else None, host=host)
203
+ if not label:
204
+ sb.name = cname # anonymous: its random container name is all there is
205
+ sb._running = state == "running"
206
+ boxes.append(sb)
207
+ return boxes
208
+
209
+
210
+ def create_sandbox(
211
+ image: str | None = None,
212
+ *,
213
+ gui: bool = False,
214
+ limits: Limits = Limits(),
215
+ network: str = "bridge",
216
+ lifetime: int | None = None,
217
+ idle_ttl: int | None = None,
218
+ name: str | None = None,
219
+ host: str | None = None,
220
+ runtime: str = "runsc",
221
+ ) -> Sandbox:
222
+ """Create and start a gVisor sandbox. Use `with create_sandbox(...) as sb:` or call sb.stop() yourself.
223
+
224
+ image Image to run. Default: ubuntu:24.04, or the GNOME desktop image when gui=True.
225
+ gui True -> GNOME desktop over RDP (sb.rdp_port, sb.password, sb.desktop_token()). start() returns once the
226
+ desktop has booted and RDP listens (~30-60 s).
227
+ limits Limits(cpus, memory, pids, shm, disk). Default: 2 cores, 2g RAM, 512 processes, 5g root disk.
228
+ Reusable across sandboxes. `disk` doesn't cover the /workspace volume of a named sandbox.
229
+ name Makes the sandbox resumable. Files in sb.data_dir (/workspace; the gnome home when gui) are kept
230
+ in a docker volume on the host machine and survive stop(), `with` exit and lifetime expiry.
231
+ create_sandbox(name=same) later gets them back: it re-attaches to the container if an earlier
232
+ session left it running (processes and installed packages intact), else makes a fresh one on the
233
+ same volume. sb.delete() erases the data. Default: anonymous, nothing is kept.
234
+ There is NO ownership check: anyone who passes the same name gets that sandbox, so namespace
235
+ names per tenant yourself. The volume has no size cap either (limits.disk covers the rest).
236
+
237
+ network "bridge" (internet access, default), "none" (headless only: gui needs its RDP port) or the name of a
238
+ docker network you created. A host firewall that drops traffic from docker0 blocks the gateway from
239
+ reaching a gui sandbox there: use a network of its own.
240
+ runtime The docker runtime, default "runsc". Name a second one (e.g. "runsc-gui", same runsc plus the GUI
241
+ flags) to keep the strict one for headless sandboxes. Must be a gVisor runtime: plain runc is
242
+ refused for gui, which runs --privileged.
243
+ lifetime Seconds until the sandbox destroys itself, even if your process dies. Headless only.
244
+ Default: lives until stop().
245
+ idle_ttl Seconds without an exec() before the sandbox destroys itself, even if your process dies. The clock
246
+ restarts when each exec() starts and ends; background processes don't count. exec(timeout=) must be
247
+ shorter. A named sandbox keeps its files, and create_sandbox(name=same) later makes a fresh
248
+ container. Combine with lifetime for a hard cap. Headless only. Fixed at creation.
249
+ host Which machine supplies the resources. None -> docker on this machine (or $DOCKER_HOST).
250
+ "ssh://user@host" -> docker on a remote VM/server, e.g. an EC2 or Azure VM.
251
+ """
252
+ return Sandbox(image, gui=gui, limits=limits, network=network, lifetime=lifetime, idle_ttl=idle_ttl,
253
+ name=name, host=host, runtime=runtime).start()
@@ -0,0 +1,177 @@
1
+ Metadata-Version: 2.5
2
+ Name: agvisor
3
+ Version: 0.1.0
4
+ Summary: gVisor sandboxes for coding agents, with resource limits and optional GUI
5
+ Author: Sadok Barbouche
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Requires-Python: >=3.12
10
+ Provides-Extra: web
11
+ Requires-Dist: cryptography>=42; extra == 'web'
12
+ Description-Content-Type: text/markdown
13
+
14
+ # agvisor
15
+
16
+ gVisor (`runsc`) sandboxes for coding agents. Thin wrapper over the `docker` CLI: CPU/memory/pids limits, optional GNOME desktop over RDP.
17
+
18
+ > **Alpha, and not security-audited.** Read [Security](#security) before running code you don't trust.
19
+
20
+ ## Host setup (Linux VM or server)
21
+
22
+ Install [gVisor](https://gvisor.dev/docs/user_guide/install/), then register the runtime in `/etc/docker/daemon.json` and `systemctl reload docker` (a *restart* bounces every running container unless `live-restore` is on).
23
+ The two `runtimeArgs` are only needed for GUI sandboxes (systemd needs cgroup v2, `sudo` needs suid). Register them as a **second** runtime, so headless sandboxes keep the strict one, and pass `runtime="runsc-gui"` with `gui=True`:
24
+
25
+ ```json
26
+ {"runtimes": {"runsc": {"path": "/usr/local/bin/runsc"},
27
+ "runsc-gui": {"path": "/usr/local/bin/runsc",
28
+ "runtimeArgs": ["--in-sandbox-cgroup=v2", "--allow-suid"]}}}
29
+ ```
30
+
31
+ (Putting the args on `runsc` itself also works and needs no `runtime=`, but then every sandbox on the host gets them.)
32
+
33
+ **On AWS/Azure/GCP VMs:** with `network="bridge"` a sandbox can reach the cloud metadata endpoint (`169.254.169.254`) and read the VM's instance credentials. Block it on the host (untested here): `sudo iptables -I DOCKER-USER -d 169.254.169.254 -j DROP`, or use `network="none"`.
34
+
35
+ GUI image (x86_64), from the [gVisor systemd post](https://gvisor.dev/blog/2026/09/17/systemd-in-gvisor/):
36
+
37
+ ```sh
38
+ git clone https://github.com/google/gvisor.git && cd gvisor
39
+ docker build -t systemd-debian-gnome \
40
+ -f images/systemd-user/systemd-debian-gnome/Dockerfile.x86_64 images/systemd-user/systemd-debian-gnome
41
+ ```
42
+
43
+ The image is ~8 GB and the build takes ~30 min on 2 vCPUs (checked on a 2 vCPU / 4 GB VM: peak ~1.5 GB RAM). A desktop needs about 0.7 GB RAM idle, so give it `memory="1536m"` or more.
44
+
45
+ **Host firewall:** if yours drops traffic from `docker0` (e.g. `INPUT -i docker0 -j DROP`, a common way to keep sandboxes away from the host), the gateway can't reach a desktop on the default bridge: guacd hangs without an error. Create a network for desktops (`docker network create desktops`) and pass `network="desktops"`. Your egress rules don't apply to it, so don't run untrusted code there.
46
+
47
+ ## Use
48
+
49
+ ```python
50
+ from agvisor import Limits
51
+ from agvisor.sandbox import create_sandbox # creates AND starts the sandbox
52
+
53
+ SMALL = Limits(cpus=1, memory="512m", pids=128) # define once, reuse for any number of sandboxes
54
+
55
+ with create_sandbox(limits=SMALL, lifetime=3600) as sb: # headless
56
+ r = sb.exec("python3 -c 'print(1+1)'", timeout=30)
57
+ print(r.returncode, r.stdout)
58
+
59
+ with create_sandbox(gui=True, limits=Limits(cpus=4, memory="6g")) as sb: # desktop
60
+ print(f"RDP 127.0.0.1:{sb.rdp_port} user=gnome password={sb.password}")
61
+ input("press enter to destroy")
62
+
63
+ sb = create_sandbox(host="ssh://user@my-vm") # same thing, on a remote VM/server (e.g. EC2, Azure)
64
+ sb.stop() # without `with`, you stop it yourself
65
+ ```
66
+
67
+ | param | meaning |
68
+ |---|---|
69
+ | `image` | default `ubuntu:24.04`, or the GNOME image when `gui=True` |
70
+ | `gui` | GNOME desktop over RDP, with a random RDP password (not the image's `gnome:gnome`). `start()` waits for the desktop to boot: ~30-60 s |
71
+ | `runtime` | docker runtime, default `runsc`. With a separate GUI runtime: `"runsc-gui"`. Must be a gVisor runtime: plain `runc` is refused for `gui` (it runs `--privileged`, which is only simulated under gVisor) |
72
+ | `limits` | `Limits(cpus, memory, pids, shm, disk)`: default 2 cores, `2g`, 512 processes, docker's `/dev/shm` size, `5g` root disk. Zero or negative values are rejected (docker and runsc would read 0 as unlimited) |
73
+ | `network` | `"bridge"` (internet, default), `"none"` (headless only) or the name of a docker network you created |
74
+ | `lifetime` | seconds until the sandbox deletes itself, even if your process dies (headless only) |
75
+ | `idle_ttl` | seconds without an `exec()` before the sandbox deletes itself, even if your process dies (headless only). Expiry can lag by up to ~5s. See below |
76
+ | `name` | makes the sandbox resumable, see below |
77
+ | `host` | `None` = docker on this machine (or `$DOCKER_HOST`); `"ssh://user@host"` = docker on a remote VM/server |
78
+
79
+ `sb.exec(cmd, input=None, timeout=120)` runs `sh -c cmd` inside and returns a `CompletedProcess`; on timeout the command is killed and `returncode` is 124. That relies on GNU `timeout`, so use Debian/Ubuntu-based images (`python:3.12-slim`, `ubuntu`). On Alpine (busybox) the code is 143 and only the shell is killed: the children of a compound command (`a; b`) keep running. Also don't set an `ENTRYPOINT` in your image: agvisor starts it with `sleep infinity` as arguments.
80
+
81
+ Under gVisor, the limits behave differently from plain docker (checked on real `runsc`):
82
+ - `pids` budgets the sandbox runtime's *host threads*, not guest processes: an idle sandbox already uses ~35. Keep the default (512): at 64–128, a fork storm made `fork` fail with `Out of memory` and, pushed harder, killed the sandbox.
83
+ - Going over `memory` or `pids` kills the **whole sandbox**, not one process, and the host doesn't notice. Afterwards `exec` returns `returncode` 1 or 128 with `is not running` or `WaitPID` in stderr. Call `create_sandbox(name=...)` again: it starts a fresh container on the same `/workspace` volume.
84
+ - `disk` caps the **root filesystem** (runsc's `overlay2` annotation, checked on release-20261026.0): past it, writes fail with `No space left on device` and the sandbox keeps running. Whole number + `k/m/g/t`: `1.5g` and `2gb` are rejected. It does **not** cover `/tmp`, which gVisor backs with RAM (counted in `memory`), nor the `/workspace` volume of a named sandbox: that is a plain host directory with **no size cap**, so a runaway agent can fill the host's disk through it. Until that has a cap, keep `/var/lib/docker` on a disk of its own, and don't share it with anything you care about. `tests/test_gvisor_disk.py` checks the cap against real gVisor.
85
+ - Inside, `uname -r` reports `4.19.0-gvisor` and `/sys/fs/cgroup` and `/sys/class/net` don't exist (use `/proc/net/dev`).
86
+
87
+ ### Resuming a sandbox
88
+
89
+ ```python
90
+ with create_sandbox("python:3.12", name="my-project") as sb: # session 1
91
+ sb.exec("git clone https://github.com/me/repo && python -m venv .venv && .venv/bin/pip install -r repo/requirements.txt")
92
+ # the container is gone (CPU/RAM freed), /workspace is kept
93
+
94
+ with create_sandbox("python:3.12", name="my-project") as sb: # session 2, days later
95
+ sb.exec("ls -a") # -> repo/ .venv/ (cwd is /workspace)
96
+
97
+ create_sandbox(name="my-project").delete() # forget it: erases the data too
98
+ ```
99
+
100
+ - **Kept:** everything under `sb.data_dir` (`/workspace`; the `gnome` home for `gui=True`), in a docker volume `agvisor-<name>`.
101
+ - **Not kept:** processes, and anything outside `data_dir` (e.g. `apt install`): gVisor discards the rootfs overlay when the container is destroyed. Work in `/workspace` (venvs, repos, caches), or bake system packages into your image.
102
+ - **Keep everything:** don't call `stop()`. The container stays running and the next `create_sandbox(name=...)` re-attaches to it with processes and installed packages intact. (`limits`/`image` are then ignored: they were fixed at creation.)
103
+ - The volume lives on the docker host. On a cloud VM, keep `/var/lib/docker` on a persistent disk (EBS / managed disk), not instance-store, and resume on the same VM. Don't set `--overlay2=all:*` in the `runsc` args: it would stop volumes persisting.
104
+
105
+ For a remote host the RDP port is on that host's `127.0.0.1`: tunnel it with `ssh -L`.
106
+ `network="none"` cuts network access (headless only: GUI needs the published port).
107
+
108
+ ### Expiry and cleanup
109
+
110
+ ```python
111
+ from agvisor import list_sandboxes
112
+
113
+ sb = create_sandbox(name="u42", idle_ttl=900, lifetime=4 * 3600) # gone after 15 idle minutes, or 4h at most
114
+ for sb in list_sandboxes(host="ssh://user@my-vm"): # every agvisor sandbox on that docker host
115
+ print(sb.name); sb.stop() # exec(), stop() and delete() work on these handles
116
+ ```
117
+
118
+ - `idle_ttl`: the clock restarts when each `exec()` starts and ends; background processes don't count. `exec(timeout=)` must be shorter than `idle_ttl` (else `ValueError`), so a command can't outlive the sandbox. It is fixed at creation, like `limits`.
119
+ - A named sandbox that expires keeps its files: the next `create_sandbox(name=...)` makes a fresh container on the same volume. `delete()` is what erases them: run it from your own retention job.
120
+ - `list_sandboxes()` also finds anonymous sandboxes left behind by a crashed process. Don't `start()` a stopped handle: it would make a fresh default-image container.
121
+
122
+ ## GUI in a web page (React, Next.js, Vue, plain HTML)
123
+
124
+ Browsers can't speak RDP, so there is a gateway in between: `browser <-wss-> gateway/gateway.js <-> guacd <-RDP-> sandbox`.
125
+ Start both on the docker host with the compose file (`pip install 'agvisor[web]'` for the token helper):
126
+
127
+ ```sh
128
+ export AGVISOR_KEY=$(openssl rand -hex 32) # shared by the gateway and your backend; compose refuses to start without it
129
+ docker compose up -d --build # guacd + gateway, both on 127.0.0.1; gateway on :8090 (GATEWAY_PORT to change)
130
+ ```
131
+
132
+ The compose file pins both images by digest, binds `guacd` to loopback (its image defaults to `0.0.0.0`, an open RDP proxy on host networking), and runs the gateway as a non-root, read-only container. Linux only: both use the host network. Without Docker: `cd gateway && npm install && npm start`, plus `guacd` bound to `127.0.0.1`.
133
+
134
+ Put the gateway behind your TLS reverse proxy (so the page uses `wss://`) and your auth. In your backend, after the user is authorised:
135
+
136
+ ```python
137
+ sb = create_sandbox(gui=True)
138
+ token = sb.desktop_token(bytes.fromhex(os.environ["AGVISOR_KEY"]), ttl=60) # server-side only: it holds the RDP password
139
+ # return {"gateway": "wss://your.host/desktop", "token": token} to the page
140
+ ```
141
+
142
+ In the page, give the element a size and pass both values (the component is `web/`, npm package `agvisor-desktop`):
143
+
144
+ ```jsx
145
+ import "agvisor-desktop"; // React / Next.js ('use client'): safe to import during SSR, it does nothing on the server
146
+ <agvisor-desktop gateway={gateway} token={token} style={{ width: "100%", height: 600 }} />
147
+ ```
148
+
149
+ ```html
150
+ <!-- Vue: compilerOptions.isCustomElement = (tag) => tag === "agvisor-desktop"; plain HTML: <script type="module"> -->
151
+ <agvisor-desktop :gateway="gateway" :token="token" style="width:100%;height:600px"></agvisor-desktop>
152
+ ```
153
+
154
+ A new `token` reconnects. The element fires an `error` event (`e.detail` = message) when the connection fails or drops, e.g. a rejected or expired token: mint a fresh one and set it. TypeScript needs a JSX `IntrinsicElements` entry for the tag.
155
+ A token only has to be valid when the session *opens*, but anyone holding it can open one until `ttl` runs out.
156
+ In `web/`: `npm test` checks the token round trip against the real gateway with a fake guacd; `npm run test:browser` runs the component in real Chrome (render, mouse, keyboard, resize, errors, cleanup).
157
+
158
+ ## Security
159
+
160
+ **This is alpha software and nobody has audited it.** gVisor shrinks the part of the kernel that sandboxed code can reach; it is not a guarantee, and this library's own code has not been reviewed for flaws. Don't use it as the boundary for hostile multi-tenant workloads without a review of your own, and keep `runsc` up to date (tested with release-20261026.0). Known gaps:
161
+
162
+ - **Whoever can run `docker` on the host controls every sandbox**, and is effectively root on that host. agvisor adds no authentication or authorization: put your own in front of it.
163
+ - **`name=` has no ownership check.** `create_sandbox(name="x")` re-attaches to whatever running container is called `agvisor-x`, removes a stopped one of that name, and `list_sandboxes()` returns every sandbox on the host. If a tenant can choose the name, they can attach to, run commands in, stop and `delete()` another tenant's sandbox. Build names yourself (e.g. `f"{tenant_id}-{run_id}"`) and check ownership before you pass them in.
164
+ - **`/workspace` has no size cap.** `Limits(disk=)` only caps the root filesystem. The `/workspace` volume of a named sandbox is a plain host directory, so a runaway or hostile process can fill the host's disk through it. Anonymous sandboxes have no volume: their writes fall under `disk` (and RAM-backed `/tmp` under `memory`). For named ones, put `/var/lib/docker` on a disk of its own, so a full disk doesn't take other services down with it.
165
+ - **Sandboxes on one docker network can reach each other.** That is Docker's default: every sandbox on `"bridge"` (the default) or on a network you share between tenants can connect to the others' open ports. Use `network="none"`, a separate network per tenant or run, or a network created with `-o com.docker.network.bridge.enable_icc=false` (from Docker's documentation; not tested with agvisor).
166
+ - **`network="bridge"` also reaches the outside:** the internet, services listening on the host itself (through the bridge gateway), and on a cloud VM the metadata endpoint that serves instance credentials (see Host setup). Block what you don't want on the host's firewall, or use `network="none"`.
167
+ - **`gui=True` runs `--privileged`.** Under gVisor that only grants simulated capabilities; under plain `runc` it would be real, so agvisor refuses `runc` for GUI sandboxes. The RDP password is random per sandbox and the gateway token is encrypted and authenticated, but anyone holding a token can open a session until it expires.
168
+
169
+ To report a vulnerability, email sadokbarbouche1@outlook.com instead of opening a public issue (details in `SECURITY.md`).
170
+
171
+ ## CI and dependency updates
172
+
173
+ - `.github/workflows/ci.yml` runs on every PR, every push to `main`, and weekly (new advisories appear without commits):
174
+ `test` (Python tests, the gateway token round trip, and the component in real Chrome), `audit` (`pip-audit` on the locked Python deps, `npm audit` + registry-signature check on `web/`), `semgrep` (code scan of the Python, the JS and the workflows).
175
+ - `.github/dependabot.yml` opens weekly update PRs for `uv`, `npm` and the GitHub Actions themselves (minor+patch grouped, 7-day cooldown). Those PRs run the same checks.
176
+ - Not configurable from a workflow, set these in the repo settings: make `test`, `audit` and `semgrep` required checks on `main`, and turn on Dependabot alerts.
177
+ - Run it locally: `uv run python tests/test_args.py`, then in `web/`: `npm test` and `npm run test:browser` (after `npm ci` in both `web/` and `gateway/`), plus `uvx pip-audit …`, `uvx semgrep …` (exact commands in `ci.yml`).
@@ -0,0 +1,8 @@
1
+ agvisor/__init__.py,sha256=h-1ms1prVX4wyA9kWnBBcD2hjy7FlDvw3z-HUMzFqMc,248
2
+ agvisor/desktop.py,sha256=TDZNOrOtAxybpIUAmbrp0hlkh5T61OHWTIS1huDeuCw,2294
3
+ agvisor/limits.py,sha256=iz-nNa0sobFvVYSCe5CQ_zOuJ9wPISVZmUouQWRxbLM,1558
4
+ agvisor/sandbox.py,sha256=InF_qB2OshVI3L6ijHSntoTIHRfKJf87J3lIz0c-y0E,15461
5
+ agvisor-0.1.0.dist-info/METADATA,sha256=_5zhNA4DhWMENagI5Uq1BJg8SCbXY1ukADPW9zkEu6M,15183
6
+ agvisor-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
7
+ agvisor-0.1.0.dist-info/licenses/LICENSE,sha256=NZFmIqfLTHG2I92UuRS0w_wV6TR1MEKhSnX2mxN_EAo,1072
8
+ agvisor-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sadok Barbouche
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.