cVisor — Erlang SDK

View Source

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

  • 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