A persistent, stateful sandbox.
Unlike ExBashkit.exec/1 — which runs each script in a fresh interpreter —
a session is a long-lived bashkit::Bash whose state carries across calls:
environment variables, the current working directory, the in-memory virtual
filesystem, shell functions, and aliases all persist from one exec/2 to the
next, exactly as in an interactive shell.
The handle is an opaque resource. Hold it for as long as you want the state to
live; it is reclaimed when it is garbage-collected. A session serializes its
own calls — concurrent exec/2 calls on the same session run one at a time.
Example
iex> session = ExBashkit.Session.new()
iex> {:ok, _} = ExBashkit.Session.exec(session, "export COUNT=1")
iex> {:ok, result} = ExBashkit.Session.exec(session, "echo $COUNT")
iex> result.stdout
"1\n"Options
new/1 accepts builder options that seed the session's initial state:
:env- a map or keyword list of environment variables to pre-set. Keys and values are stringified (env: %{"LANG" => "C"}orenv: [LANG: "C"]).:cwd- the starting working directory (default/).:username- the virtual username reported bywhoami/id(default"sandbox").:hostname- the virtual hostname reported byhostname/uname -n(default"bashkit-sandbox").:files- a map (or keyword list) ofpath => contentto seed into the virtual filesystem before the first call, creating parent directories as needed. Content is anyiodata/0. Equivalent to callingwrite_file/3for each entry.:mounts- a list of{vfs_path, host_path, mode}tuples mapping a real host directory into the sandbox (see "Host mounts" below).modeis:read_onlyor:read_write.:allowed_mount_paths- a list of host paths that may be mounted even though they fall under a directory bashkit refuses by default (see below).:limits- a keyword list or map of resource limits enforced during execution (see "Resource limits" below).:allow_net- a list of URL patterns thecurl/wget/httpbuiltins may reach, or:allto permit any host (see "Network access" below). Omitted, the network is unreachable.:block_private_ips- whether to block requests that resolve to private or reserved IP ranges (defaulttrue; see "Network access").:builtins- a map ofname => funregistering Elixir-defined virtual executables a script can invoke (see "Custom builtins" below).:builtin_timeout_ms- how long a single custom-builtin back-call may run before it is abandoned (positive integer, default30_000). Also bounds:virtual_fsback-calls.:virtual_fs- a map ofmount_path => backendmounting Elixir-backed filesystems whose reads and writes are serviced by your application (see "Virtual filesystem backends" below andExBashkit.VirtualFs).:python-true(or[name: ...]/[names: [...]]) to register a sandboxedpython/python3builtin that shares the session filesystem. Requires the optional:ex_montydependency (seeExBashkit.Python).
Virtual filesystem
A session's in-memory filesystem is shared between scripts and the host. Use
write_file/3 to place files (e.g. inputs) and read_file/2 to pull files
back out (e.g. results a script produced) without going through a script:
iex> session = ExBashkit.Session.new(files: %{"/in.txt" => "data\n"})
iex> {:ok, _} = ExBashkit.Session.exec(session, "wc -l < /in.txt > /out.txt")
iex> ExBashkit.Session.read_file(session, "/out.txt")
{:ok, "1\n"}Host mounts
By default nothing on the real host is reachable. The :mounts option maps a
real host directory into the sandbox's filesystem:
ExBashkit.Session.new(
mounts: [
{"/data", "/srv/app/data", :read_only},
{"/work", "/tmp/sandbox-work", :read_write}
]
):read_only— scripts can read host files; writes fail.:read_write— scripts can read and modify real host files. A footgun; use a dedicated directory.
bashkit enforces all isolation: paths are canonicalized, .. traversal and
symlinks that escape the mounted directory are rejected, so a mount of
/srv/app/data can never reach /srv/app/secrets.
By default bashkit refuses to mount sensitive host locations — /etc,
/proc, /sys, /dev, /home, /Users, /private (which on macOS includes
temp dirs under /var/folders), and paths containing .ssh/.aws/etc. To
mount under one deliberately, list a covering prefix in :allowed_mount_paths.
Note this is a switch, not additive: once you set :allowed_mount_paths, the
built-in sensitive-path denylist is off and the allowlist becomes the sole
gate — every mount's host path must then sit under some allowlisted prefix.
A misconfigured mount raises from new/1 — unknown mode, a missing or
non-directory host path, or a host path bashkit refuses (sensitive with no
covering allowlist entry).
Network access
By default a session cannot reach the network at all — curl, wget, and
http fail. Grant access with :allow_net, an allowlist of URL patterns:
session =
ExBashkit.Session.new(
allow_net: ["https://api.example.com", "https://cdn.example.com/assets"]
)
{:ok, result} = ExBashkit.Session.exec(session, "curl -s https://api.example.com/v1/health")The allowlist is default-deny: only requests whose scheme, host, port, and
path-prefix match a pattern are permitted; everything else fails. Matching is
literal (no DNS resolution at match time), and redirects are not followed, so a
response cannot bounce a script to an unlisted host. allow_net: :all lifts the
allowlist entirely — only safe for fully trusted scripts.
Independently, requests that resolve to private or reserved IP ranges
(loopback, 10/8, 172.16/12, 192.168/16, link-local, ULA, …) are blocked
by default, even if the URL is on the allowlist. This stops a script from
reaching internal services via SSRF / DNS-rebinding. To talk to a private
address on purpose (e.g. a localhost dev server), set block_private_ips: false
— understand the SSRF exposure before you do.
Resource limits
bashkit already bounds execution with safe defaults; :limits tightens them
for untrusted scripts. Each key is optional; unset keys keep bashkit's default.
When a script exceeds a limit, exec/2 returns {:error, message}.
session = ExBashkit.Session.new(limits: [max_commands: 1_000, timeout_ms: 2_000]):max_commands- total commands a script may run (fuel; default 10,000).:max_loop_iterations- iterations of any single loop (default 10,000).:max_total_loop_iterations- iterations across all loops, defeating the nested-loop multiplication trick (default 1,000,000).:max_function_depth- recursion depth (default 100).:max_input_bytes- maximum script size in bytes (default 10,000,000).:timeout_ms- wall-clock execution timeout in milliseconds (default 30,000; must be ≥ 1). Because a running script holds a dirty scheduler thread for its duration, a large:timeout_msunder heavy concurrency can starve the (bounded) dirty-CPU pool — keep it as tight as the workload allows.
Count limits must be non-negative integers (a value past the platform maximum
means "unlimited"); unknown keys raise ArgumentError.
Custom builtins
:builtins registers Elixir-defined virtual executables: a script line
name args… calls back into your application, which computes the command's
output. This is how a sandbox reaches capabilities you control — a database
query, a key/value lookup, an approval prompt — without giving the script real
process or network access.
session =
ExBashkit.Session.new(
builtins: %{
"kv_get" => fn call ->
case Store.fetch(hd(call.args)) do
{:ok, value} -> {:ok, value <> "\n"}
:error -> {:error, "no such key\n"}
end
end
}
)
{:ok, %ExBashkit.Result{stdout: "42\n"}} =
ExBashkit.Session.exec(session, "total=$(kv_get answer); echo $total")Each builtin is a 1-arity function receiving one map:
:args- the command's arguments (a list of strings), excluding the name.:stdin- input piped from the previous command (""if none).:env- the session's environment variables (a%{String.t => String.t}).
It returns a tagged result:
{:ok, iodata}- success; the iodata becomes stdout, exit code0.{:error, iodata}- failure; the iodata becomes stderr, exit code1.%ExBashkit.Result{}- full control over stdout, stderr, and exit code.
Anything else is treated as a contract violation: the command fails (exit 1)
with a descriptive stderr message rather than crashing the session.
Robustness. A builtin that raises, or runs longer than :builtin_timeout_ms
(exit 124), fails only that command — the session stays usable.
No reentrancy. A builtin handler must not call exec/2 on the same
session; that exec already holds the session's lock, so the call would block.
Driving a different session from inside a builtin is fine. Each exec/2
services back-calls from a short-lived process, so builtins run outside the
caller's process and must not rely on its process dictionary or self().
Virtual filesystem backends
:virtual_fs mounts an Elixir-backed filesystem at a vfs path: a script's
reads and writes under that path are serviced by your application, so "files"
can be generated on demand or proxied to a real store. It composes with the
in-memory FS, :files, and host :mounts.
session =
ExBashkit.Session.new(
virtual_fs: %{
"/api" => fn
%{op: :read, path: path} -> {:ok, render(path)}
_ -> {:error, :enotsup}
end
}
)
{:ok, %ExBashkit.Result{stdout: out}} =
ExBashkit.Session.exec(session, "cat /api/users/1.json")A backend is a virtual_fs_spec/0 — a module implementing ExBashkit.VirtualFs,
a {module, arg} pair, or a single dispatch function for inline use. Paths
arrive rooted at the mount. The same failure model and
:builtin_timeout_ms as custom builtins apply, and the same no-reentrancy
rule. See ExBashkit.VirtualFs for the full contract.
Summary
Types
A filesystem entry's kind, as reported by stat/2 and list_dir/2.
A resource-limit key accepted by the :limits option.
Access mode for a host directory mount.
A virtual-filesystem backend for one mount: a 1-arity function, a module
implementing ExBashkit.VirtualFs, or {module, arg}.
Functions
Execute script against session, mutating it in place.
List the entries of directory path in the session's filesystem.
Create directory path in the session's filesystem.
Create a new persistent session, optionally seeding its initial state.
Read the contents of path from the session's virtual filesystem.
Remove path from the session's filesystem.
Rename/move from to to within the session's filesystem.
Restore previously captured state (from snapshot/2) into this session,
returning {:ok, session} or {:error, message}.
Capture the session's interpreter state as a binary you can persist and later
reload with restore/3.
Return metadata for path in the session's filesystem.
Write content (any iodata/0) to path in the session's virtual
filesystem, creating parent directories as needed.
Types
@type fs_type() :: :file | :dir | :symlink
A filesystem entry's kind, as reported by stat/2 and list_dir/2.
@type limit_key() ::
:max_commands
| :max_loop_iterations
| :max_total_loop_iterations
| :max_function_depth
| :max_input_bytes
| :timeout_ms
A resource-limit key accepted by the :limits option.
@type mount_mode() :: :read_only | :read_write
Access mode for a host directory mount.
@type option() :: {:env, %{optional(String.t() | atom()) => String.t()} | keyword()} | {:cwd, Path.t()} | {:username, String.t()} | {:hostname, String.t()} | {:files, %{optional(Path.t()) => iodata()} | [{Path.t(), iodata()}]} | {:mounts, [{Path.t(), Path.t(), mount_mode()}]} | {:allowed_mount_paths, [Path.t()]} | {:limits, %{optional(limit_key()) => non_neg_integer()} | [{limit_key(), non_neg_integer()}]} | {:allow_net, [String.t()] | :all} | {:block_private_ips, boolean()} | {:builtins, %{optional(String.t()) => (map() -> term())}} | {:builtin_timeout_ms, pos_integer()} | {:virtual_fs, %{optional(Path.t()) => virtual_fs_spec()}} | {:python, boolean() | keyword()}
@opaque t()
A virtual-filesystem backend for one mount: a 1-arity function, a module
implementing ExBashkit.VirtualFs, or {module, arg}.
Functions
@spec exec(t(), String.t()) :: {:ok, ExBashkit.Result.t()} | {:error, String.t()}
Execute script against session, mutating it in place.
Any env/cwd/filesystem/function changes the script makes persist for the next
call. Returns {:ok, %ExBashkit.Result{}} on success — a non-zero exit_code
is still {:ok, ...}, just like a real shell — or {:error, message} if the
script could not be parsed or the interpreter itself errored.
Examples
iex> session = ExBashkit.Session.new()
iex> {:ok, _} = ExBashkit.Session.exec(session, "cd /tmp")
iex> {:ok, result} = ExBashkit.Session.exec(session, "pwd")
iex> result.stdout
"/tmp\n"
List the entries of directory path in the session's filesystem.
{:ok, [{name, :file | :dir | :symlink}]} (names are the immediate children,
not full paths), or {:error, message} if path is not a readable directory.
Examples
iex> session = ExBashkit.Session.new()
iex> {:ok, _} = ExBashkit.Session.exec(session, "mkdir /d; echo x > /d/a.txt")
iex> ExBashkit.Session.list_dir(session, "/d")
{:ok, [{"a.txt", :file}]}
Create directory path in the session's filesystem.
Returns :ok or {:error, message}. With parents: true it creates any
missing parent directories (like mkdir -p) and succeeds if the directory
already exists; without it, a missing parent is an error.
Options
:parents- create missing parents (defaultfalse).
Create a new persistent session, optionally seeding its initial state.
See the module doc for the supported opts. Construction is infallible for
the in-memory options; malformed options raise (they are a programmer error).
Raises ArgumentError if a :files entry cannot be written, or if a :mounts
entry is invalid (unknown mode, or a host path that is missing or not a
directory).
Read the contents of path from the session's virtual filesystem.
Returns {:ok, binary} — including for files a script wrote — or
{:error, message} if the file does not exist or cannot be read. The result
is a raw binary, so it round-trips arbitrary (including non-UTF-8) content.
As with write_file/3, path is resolved from the filesystem root,
independent of the session's working directory; pass absolute paths. A path
under a host mount reads the real host file; a path under a :virtual_fs
mount is unsupported here (returns {:error, _} when idle, and is not
guaranteed otherwise — don't rely on it).
Examples
iex> session = ExBashkit.Session.new()
iex> {:ok, _} = ExBashkit.Session.exec(session, "echo result > /out.txt")
iex> ExBashkit.Session.read_file(session, "/out.txt")
{:ok, "result\n"}
Remove path from the session's filesystem.
Returns :ok or {:error, message}. Removing a non-empty directory requires
recursive: true.
Options
:recursive- remove a directory and its contents (defaultfalse).
Rename/move from to to within the session's filesystem.
Returns :ok or {:error, message}.
Restore previously captured state (from snapshot/2) into this session,
returning {:ok, session} or {:error, message}.
Restore overwrites the session's shell state and in-memory filesystem contents
while preserving the capabilities session was built with — its custom
:builtins, :virtual_fs backends, host :mounts, and :limits survive. The
intended flow is therefore:
{:ok, bytes} = ExBashkit.Session.snapshot(original)
# ...later, or on another node...
resumed = ExBashkit.Session.new(builtins: same, virtual_fs: same, limits: same)
{:ok, resumed} = ExBashkit.Session.restore(resumed, bytes)bashkit validates the whole snapshot before mutating anything, so a malformed,
tampered, or wrong-key snapshot returns {:error, _} and leaves the session
untouched and usable. Keying is symmetric and all-or-nothing: bytes taken with
a :key must be restored with the same key, and plain bytes must be
restored without one — a mismatch is an {:error, _}.
Options
:key— the binary secret the snapshot was taken with (seesnapshot/2).
Examples
iex> session = ExBashkit.Session.new()
iex> {:ok, %ExBashkit.Result{}} = ExBashkit.Session.exec(session, "x=42")
iex> {:ok, bytes} = ExBashkit.Session.snapshot(session)
iex> fresh = ExBashkit.Session.new()
iex> {:ok, fresh} = ExBashkit.Session.restore(fresh, bytes)
iex> {:ok, result} = ExBashkit.Session.exec(fresh, "echo $x")
iex> result.stdout
"42\n"
Capture the session's interpreter state as a binary you can persist and later
reload with restore/3.
The snapshot carries shell state (variables, exported env, cwd, arrays, aliases, traps, and — unless excluded — functions) and the in-memory filesystem contents. It is taken at a command boundary; there is no pause-mid-command.
It does not carry session configuration: custom :builtins,
:virtual_fs backends, host :mounts, :limits, or network settings. Those
are live Elixir processes / builder config, not interpreter state. To resume,
build a fresh session with the same capabilities and restore/3 the bytes
into it (see restore/3).
Returns {:ok, binary} or {:error, message}.
Options
:key— a binary secret. Produces an HMAC-keyed snapshot for crossing a trust boundary (network, shared storage, untrusted storage). Restore must supply the same key; a wrong key or tampered bytes are rejected. Without a key, bashkit's integrity tag detects accidental corruption only (it is public, not a forgery defense).:exclude_filesystem— whentrue, capture shell state only (skip VFS contents). Defaultfalse.:exclude_functions— whentrue, skip shell functions (avoids cloning AST-backed state). Defaultfalse.
Examples
iex> session = ExBashkit.Session.new()
iex> {:ok, %ExBashkit.Result{}} = ExBashkit.Session.exec(session, "x=42")
iex> {:ok, bytes} = ExBashkit.Session.snapshot(session)
iex> is_binary(bytes)
true
@spec stat(t(), Path.t()) :: {:ok, %{type: fs_type(), size: non_neg_integer()}} | {:error, String.t()}
Return metadata for path in the session's filesystem.
{:ok, %{type: :file | :dir | :symlink, size: non_neg_integer()}}, or
{:error, message} if the path does not exist. Like read_file/2, it resolves
from the filesystem root and reads through host mounts; it is lock-free, so it
also works while a script is running.
Examples
iex> session = ExBashkit.Session.new()
iex> {:ok, _} = ExBashkit.Session.exec(session, "printf 12345 > /f")
iex> ExBashkit.Session.stat(session, "/f")
{:ok, %{type: :file, size: 5}}
Write content (any iodata/0) to path in the session's virtual
filesystem, creating parent directories as needed.
Returns :ok, or {:error, message} if the write was rejected (e.g. a
filesystem limit). The file is immediately visible to subsequent exec/2
calls and to read_file/2.
path is resolved from the filesystem root, independent of the session's
working directory (the cwd lives in the interpreter, not the filesystem). Pass
absolute paths; a relative path like "out.txt" is treated as "/out.txt".
If path falls under a :read_write host mount, the write reaches the real
host disk (and fails under a :read_only mount), just as it would for a script.
This targets the in-memory and host-mount filesystem; it is not the way to feed
a :virtual_fs mount (those are driven by your backend, only while a script
runs). A path under a :virtual_fs mount is unsupported here — it returns
{:error, _} when the session is idle, though it may be serviced by the backend
if a script happens to be running concurrently. Don't rely on either.
Examples
iex> session = ExBashkit.Session.new()
iex> ExBashkit.Session.write_file(session, "/etc/motd", "welcome\n")
:ok
iex> {:ok, result} = ExBashkit.Session.exec(session, "cat /etc/motd")
iex> result.stdout
"welcome\n"