cVisor — Python SDK #
A ctypes FFI wrapper over the libcvisor C ABI. Linux-only.
Quick try (Docker) #
Drop into a Python REPL with cvisor installed, from any machine with Docker:
docker run -it --rm \
--security-opt seccomp=unconfined --security-opt apparmor=unconfined \
ghcr.io/astral-sh/uv:python3.12-alpine \
uv run --with cvisor python
>>> from cvisor import Sandbox
>>> sb = Sandbox()
>>> print(sb.run("echo hi; uname -n").stdout)
hi
cvisor
The --security-opt flags are required: cVisor installs its own seccomp
filter, which Docker's default profiles block. An Alpine (musl) image is
needed — the published wheels are musllinux-tagged.
Install (uv) #
uv add cvisor
Usage #
from cvisor import Sandbox
with Sandbox() as sb:
out = sb.run("echo hello")
print(out.stdout) # "hello\n"
print(out.stderr) # ""
Sandbox.run(cmd) blocks until the sandboxed command exits and returns an
Output with .stdout / .stderr (str), .stdout_bytes / .stderr_bytes,
and .exit_code (int, shell convention: the command's status, or 128+signo if
it was killed by a signal).
Timeouts #
Sandbox.run(cmd, timeout_ms=...) SIGKILLs the guest after timeout_ms
milliseconds; a timed-out run reports exit code 137:
out = sb.run("sleep 30", timeout_ms=300)
assert out.exit_code == 137
Network policy #
Sandbox.set_allow_network(allow) controls outbound INET/INET6 networking
(allowed by default):
sb.set_allow_network(False) # deny outbound networking
Sandbox.set_allow_listen(allow) controls inbound TCP servers (listen/
accept), denied by default:
sb.set_allow_listen(True) # allow the guest to run TCP servers
sb.set_env("TOKEN", "xyz") # environment variable for the guest
Files #
Sandbox.write_file(path, data) writes data (bytes or str) into the
sandbox's persistent overlay, and Sandbox.read_file(path) returns the file's
bytes as the guest sees it (the overlay copy, else the real host file for cow
paths). path must be absolute; /proc and passthrough paths are not writable.
Files written are visible to later run calls of the same Sandbox instance:
with Sandbox() as sb:
sb.write_file("/tmp/data.txt", "seeded\n")
print(sb.run("cat /tmp/data.txt").stdout) # "seeded\n"
sb.run("echo from-run > /tmp/out.txt")
print(sb.read_file("/tmp/out.txt")) # b"from-run\n"
write_file raises OSError on failure; read_file returns b"" for an empty
file and raises FileNotFoundError for a missing or unreadable path.
Copying files and directories #
Sandbox.copy_into(host_path, guest_path) copies a host file — or a whole
directory tree — into the sandbox's persistent overlay, and
Sandbox.copy_out(guest_path, host_path) copies one back out to the real
filesystem. Directory copies are recursive and respect .gitignore /
.dockerignore. Copied files are visible to later run calls:
with Sandbox() as sb:
sb.copy_into("./my-project", "/work") # host dir -> overlay
sb.run("cd /work && make")
sb.copy_out("/work/build", "./build-out") # overlay -> host
Both raise OSError on failure.
Cache #
Sandbox.cache_save(sandbox_path, key, backend="", format="gzip") archives a
sandbox directory under key to a cache backend, and
Sandbox.cache_restore(sandbox_path, key, backend="", format="gzip") unpacks it
back into the overlay. This lets one sandbox persist a directory (a build tree,
a dependency cache, …) that a later, independent sandbox can restore:
with Sandbox() as sb:
sb.run("cd /work && npm install")
sb.cache_save("/work/node_modules", "npm-deps")
with Sandbox() as sb2:
sb2.cache_restore("/work/node_modules", "npm-deps") # ready to use
Both the archive and cache_save respect .gitignore / .dockerignore.
backend selects where the archive is stored:
""or"disk"— host disk (the default)"disk:/path"— host disk at an explicit location"s3://bucket/prefix?region=..&endpoint=.."— S3
S3 requires the library to be built with the s3 feature; the bundled
libcvisor.so is not, so disk is the working default.
format selects the archive format:
""or"gzip"— gzip (the default)"estargz"— seekable, lazily-pullable gzip"none"— uncompressed"zstd"— zstd, only if the library is built with zstd (the bundled lib is not)
Both methods raise OSError on failure.
Streaming output #
Sandbox.run_streaming(cmd, on_stdout=..., on_stderr=..., poll_ms=15) runs a
command and delivers output to callbacks as it is produced, instead of buffering
it all until exit. Each callback receives a decoded str chunk. Returns the
exit code:
with Sandbox() as sb:
sb.run_streaming(
"for i in 1 2 3; do echo line$i; sleep 0.1; done",
on_stdout=lambda s: print(s, end=""),
)
Interactive PTY shell #
Sandbox.shell(on_output=..., poll_ms=15) starts an interactive /bin/sh -i
session on a PTY (so test -t 1 is true and programs behave as if attached to a
terminal). It returns a Session you can drive:
with Sandbox() as sb:
sh = sb.shell(on_output=lambda s: print(s, end=""))
sh.write_stdin("echo hello\n")
sh.resize(40, 120) # rows, cols
sh.write_stdin("exit 0\n")
code = sh.wait() # blocks for the exit code
sh.close()
Session methods:
write_stdin(data)— feed input (bytesorstr); returns bytes written (PTY only).read_stdout()/read_stderr()— drain and return new bytes (a PTY merges the streams, so stderr is empty).on_outputabove does this for you on a daemon thread.resize(rows, cols)— resize the PTY.exit_code()— the exit code if the session has finished, elseNone(non-blocking).wait()— block until exit and return the code.kill()— SIGKILL the session.close()— free the session (idempotent; also a context manager and on__del__).
Remote daemon (GraphQL) — works on macOS #
The Sandbox above needs the native libcvisor (Linux only). To use cVisor
from any platform, including macOS, talk to a running
cvisord over its GraphQL API. This path is pure
stdlib (urllib.request + json) — no native library — and importing it never
loads the .so:
from cvisor import RemoteSandbox, GraphQLClient
remote = RemoteSandbox("http://127.0.0.1:8080/graphql", token)
out = remote.run("echo hello")
print(out["stdout"]) # "hello\n"
sb = remote.create_sandbox("my-box")
remote.write_file(sb["id"], "/app/x", "hi") # base64 handled for you
print(remote.read_file(sb["id"], "/app/x")) # b"hi"
snap = remote.snapshot(sb["id"])
remote.fork(sb["id"], "clone")
remote.free_sandbox(sb["id"])
RemoteSandbox mirrors the daemon surface: run, create_sandbox /
list_sandboxes / free_sandbox / configure, write_file / read_file,
cache_save / cache_restore / cache_list, snapshot / rollback /
branch / fork / snapshots / delete_snapshot, and health. For raw
documents, drop to the client:
gql = GraphQLClient("http://127.0.0.1:8080/graphql", token)
data = gql.query("{ sandboxes { id name } }")
gql.mutate("mutation($c:String!){ run(command:$c){ stdout } }", {"c": "uname -a"})
The daemon prints its bearer token on startup (or set CVISOR_TOKEN). On a
non-Linux host, constructing the FFI Sandbox raises a clear "Linux-only"
RuntimeError pointing you here.
Interactive console #
Launch an IPython REPL with a live sandbox preloaded:
uv run --extra console cvisor # or: python -m cvisor
cVisor interactive console
sb -> a Sandbox instance
sh("cmd") -> run a shell command in the sandbox, printing stdout/stderr
Sandbox -> create your own: Sandbox()
In [1]: sh("echo hello; uname -n")
hello
cvisor
Without the console extra (IPython) it falls back to the stdlib REPL.
Development #
The SDK loads libcvisor.so. Build it from the repo root and point the SDK at
it via the CVISOR_LIB environment variable, or let the package resolve a copy
bundled under cvisor/_native/:
# from the repo root — builds libcvisor.so into cvisor/_native/
cargo xtask ffi
# run the tests with uv
cd sdks/python
uv run pytest