cVisor — Erlang SDK
An Erlang NIF over the libcvisor C ABI. Runs shell commands in an
in-process Linux sandbox — no containers, no VMs, sandbox startup in
~2 milliseconds. Linux-only.
cVisor intercepts and virtualizes
Linux syscalls from userspace with the seccomp user notifier, giving the
sandboxed command a copy-on-write view of the filesystem and a virtualized
/proc. It is designed for safely running untrusted or LLM-generated
commands directly inside your application.
Install
Add the package to your rebar.config:
{deps, [{cvisor, "0.3.0"}]}.
Or, from Elixir, to your mix.exs:
{:cvisor, "~> 0.3.0"}
The prebuilt libcvisor (aarch64 and x86_64, musl) ships in the package; the
small NIF shim is compiled on install, so a C compiler (cc) must be on the
PATH.
Usage
1> {ok, Stdout, Stderr, ExitCode} = cvisor:run(<<"echo hello">>).
{ok,<<"hello\n">>,<<>>,0}
2> cvisor:run("printf 'a\nb\nc\n' | grep b").
{ok,<<"b\n">>,<<>>,0}
3> %% Writes land in the sandbox's own filesystem view, not the host.
3> cvisor:run(<<"echo secret > /tmp/f && cat /tmp/f">>).
{ok,<<"secret\n">>,<<>>,0}
4> cvisor:run(<<"uname -n">>).
{ok,<<"cvisor\n">>,<<>>,0}
5> %% run/2 SIGKILLs the guest after a timeout (ms); a timed-out run
5> %% reports exit code 137.
5> cvisor:run(<<"sleep 30">>, 300).
{ok,<<>>,<<>>,137}
6> %% Deny outbound INET/INET6 networking for subsequent runs
6> %% (allowed by default).
6> cvisor:set_allow_network(false).
ok
7> %% Allow inbound TCP servers (bind fixed port, listen, accept) for
7> %% subsequent runs and sessions (denied by default).
7> cvisor:set_allow_listen(true).
ok
8> %% Set a guest env var (layered over the default PATH/HOME) for
8> %% subsequent runs and sessions. Setting an existing key overrides it.
8> cvisor:set_env(<<"FOO">>, <<"bar">>).
ok
8> cvisor:run(<<"echo $FOO">>).
{ok,<<"bar\n">>,<<>>,0}
cvisor:run/1 accepts a binary or a string, blocks until the sandboxed
command exits (on a dirty I/O scheduler, so it does not stall the VM), and
returns {ok, Stdout, Stderr, ExitCode} or {error, Reason}. The exit code
follows shell convention: the guest's status, or 128+signo when killed by a
signal. cvisor:run/2 takes a timeout in milliseconds (0 = no limit).
cvisor:set_allow_network/1 takes a boolean and applies to sandboxes
created by subsequent runs. cvisor:set_allow_listen/1 likewise takes a
boolean and controls whether sandboxes may run inbound TCP servers (bind a
fixed port, listen, accept); it is denied by default.
cvisor:set_env/2 sets a guest environment variable (Key and Value as
binaries or strings), layered over the default PATH/HOME and applied to
sandboxes created by subsequent runs and sessions; setting a key that already
exists overrides its value.
From Elixir:
iex> :cvisor.run("echo hello from elixir")
{:ok, "hello from elixir\n", "", 0}
Streaming & interactive shells
cvisor:run/1 buffers all output and returns once the command exits. For
long-running commands you can instead stream output as it is produced, or open
an interactive PTY shell you can type into. Both are built on a session: a
sandbox plus a running command that you poll from Erlang.
run_streaming/1,2
Runs a (non-PTY) command and invokes your callbacks with each chunk of output as it arrives, blocking the calling process until the command exits and returning its exit code:
Code = cvisor:run_streaming(
<<"for i in 1 2 3; do echo line$i; sleep 0.1; done">>,
[{on_stdout, fun(Bin) -> io:put_chars(Bin) end},
{on_stderr, fun(Bin) -> io:put_chars(Bin) end},
{poll_ms, 15}]).
%% prints line1 / line2 / line3 as they appear; Code = 0
Options (all optional): {on_stdout, fun((binary()) -> any())},
{on_stderr, fun((binary()) -> any())}, and {poll_ms, integer()} (poll
interval in milliseconds, default 15). run_streaming(Cmd) is
run_streaming(Cmd, []).
shell/0,1
Opens an interactive PTY shell (/bin/sh -i) and returns an opaque session
handle. Output streams are merged (as with a real terminal), and stdin is
writable, so the command sees a TTY (test -t 1 succeeds):
{ok, S} = cvisor:shell([{on_output, fun(Bin) -> io:put_chars(Bin) end}]),
cvisor:session_write(S, <<"echo hello from the shell\n">>),
cvisor:session_resize(S, 40, 120),
cvisor:session_write(S, <<"exit 0\n">>),
Code = cvisor:session_wait(S), %% blocks until the shell exits
cvisor:session_free(S).
Options: {on_output, fun((binary()) -> any())} (if given, a poller process
is spawned that drains the merged output and calls the fun with each chunk
until the shell exits) and {poll_ms, integer()} (default 15). shell() is
shell([]). The caller owns the session and must call session_free/1 when
done (it is also freed automatically when the handle is garbage-collected).
Session functions
The lower-level session API, used by both helpers above:
| Function | Description |
|---|---|
session_start(Cmd, Pty) | Start a session. Pty is 0 (plain command) or 1 (PTY shell). |
session_read_stdout(S) | Drain and return new stdout bytes (<<>> if none; merged for PTY). |
session_read_stderr(S) | Drain and return new stderr bytes (empty for PTY sessions). |
session_write(S, Data) | Write to stdin (PTY only); returns bytes written or -1. |
session_resize(S, Rows, Cols) | Resize the PTY window. |
session_try_wait(S) | Non-blocking: {done, ExitCode} or running. |
session_wait(S) | Block (on a dirty I/O scheduler) until exit; returns the code. |
session_kill(S) | SIGKILL the session's command. |
session_free(S) | Free the session and its sandbox (idempotent). |
session_write_file(S, P, D) | Write bytes D to path P in the session's sandbox overlay. |
session_read_file(S, P) | Read path P from the session's overlay (<<>> if empty/missing). |
Seeding files into a session
A session holds a single sandbox (one stable filesystem overlay) for its lifetime, so you can seed files into it and have them visible to commands run on that same session:
{ok, S} = cvisor:shell([]),
ok = cvisor:session_write_file(S, <<"/tmp/x">>, <<"hi">>),
<<"hi">> = cvisor:session_read_file(S, <<"/tmp/x">>),
cvisor:session_write(S, <<"cat /tmp/x\n">>), %% the shell sees the file
cvisor:session_write(S, <<"exit 0\n">>),
cvisor:session_wait(S),
cvisor:session_free(S).
session_write_file/3 returns ok or {error, Reason} (an errno atom such as
eacces or enospc); session_read_file/2 returns the file's bytes, or <<>>
for an empty or missing file.
Files are scoped to the sandbox they were written to. cVisor keys the filesystem overlay by the sandbox's uid, and each top-level
run/1,run/2, andrun_streaming/2call creates a fresh sandbox with a new uid. There is therefore no way to seed a file that a laterrun/1will see — file seeding is only meaningful within a single session, whose sandbox (and uid) is stable across its runs. That is why these operations are exposed on the session handle rather than as top-level functions.
Copying files & directories
Beyond single files, you can copy whole trees between the host and a session's
sandbox overlay. Like session_write_file/3, these operate on the session's
own sandbox (a stable uid), so copied content is visible to later commands on
the same session.
{ok, S} = cvisor:shell([]),
%% Copy a host directory tree into the sandbox.
ok = cvisor:session_copy_into(S, <<"/host/project">>, <<"/tmp/project">>),
cvisor:session_write(S, <<"ls /tmp/project\n">>),
%% ...and copy results back out to the host.
ok = cvisor:session_copy_out(S, <<"/tmp/project/out">>, <<"/host/out">>),
cvisor:session_free(S).
session_copy_into/3 and session_copy_out/3 accept either a single file or a
directory tree, and return ok or {error, Reason} (an errno atom such as
enoent).
Cache
A session can save a directory (or file) from its sandbox overlay to a cache
under a key, and restore it later — for example to warm a dependency tree
across a session's commands. As with file seeding, the cache is keyed by the
session's sandbox uid, so save/restore are meaningful within a single
session (whose sandbox and uid are stable), not across independent run/1
calls.
{ok, S} = cvisor:shell([]),
ok = cvisor:session_write_file(S, <<"/tmp/proj/data.txt">>, <<"payload">>),
%% Save /tmp/proj under key "k1" (disk backend, gzip format).
ok = cvisor:session_cache_save(S, <<"/tmp/proj">>, <<"k1">>),
%% Later, restore it into a different path on the same session.
ok = cvisor:session_cache_restore(S, <<"/tmp/proj2">>, <<"k1">>),
<<"payload">> = cvisor:session_read_file(S, <<"/tmp/proj2/data.txt">>),
cvisor:session_free(S).
| Function | Description |
|---|---|
session_copy_into(S, HostPath, GuestPath) | Copy a host file/dir into the session's sandbox overlay. |
session_copy_out(S, GuestPath, HostPath) | Copy a file/dir out of the overlay to the host. |
session_cache_save(S, Path, Key) | Save Path to the cache under Key (disk backend, gzip). |
session_cache_save(S, Path, Key, Backend, Fmt) | As above, choosing the backend and archive format. |
session_cache_restore(S, Path, Key) | Restore cache entry Key into Path (disk backend, gzip). |
session_cache_restore(S, Path, Key, Backend, Fmt) | As above, choosing the backend and archive format. |
The /5 forms take a Backend and Format. Backend defaults to the disk
backend when the empty binary <<>> is given; Format defaults to <<"gzip">>
in the /3 forms and also accepts <<"estargz">> or <<"none">>. The restore
format must match the format used to save.
The prebuilt
libcvisorbundled in this package is built with the disk backend and thegzip/estargz/noneformats only. Other backends (e.g.s3) or formats (e.g.zstd) require alibcvisorbuilt with those features. All copy/cache operations are session-scoped, for the same reason file seeding is (the overlay is keyed by the sandbox's uid).
Requirements
- Linux (aarch64 or x86_64) with the seccomp user notifier (kernel >= 5.0; unprivileged, no root needed)
- Erlang/OTP with dirty schedulers (any modern OTP)
- A C compiler at install time for the NIF shim
Development
The NIF dlopens libcvisor.so. Build it from the repo root
(cargo xtask ffi), which drops priv/libcvisor-<arch>.so into this SDK, or
point the NIF at any build via the CVISOR_LIB environment variable:
# from the repo root — builds and distributes libcvisor-<arch>.so
cargo xtask ffi
cd sdks/erlang
make all # NIF shim + beam files (via erlc)
erlc -o ebin test/cvisor_test.erl
erl -noshell -pa ebin -eval "cvisor_test:run()" -s init stop