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:

FunctionDescription
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, and run_streaming/2 call creates a fresh sandbox with a new uid. There is therefore no way to seed a file that a later run/1 will 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).
FunctionDescription
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 libcvisor bundled in this package is built with the disk backend and the gzip/estargz/none formats only. Other backends (e.g. s3) or formats (e.g. zstd) require a libcvisor built 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

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