ExBashkit.Session (ExBashkit v0.1.2)

Copy Markdown View Source

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"} or env: [LANG: "C"]).
  • :cwd - the starting working directory (default /).
  • :username - the virtual username reported by whoami/id (default "sandbox").
  • :hostname - the virtual hostname reported by hostname/uname -n (default "bashkit-sandbox").
  • :files - a map (or keyword list) of path => content to seed into the virtual filesystem before the first call, creating parent directories as needed. Content is any iodata/0. Equivalent to calling write_file/3 for each entry.
  • :mounts - a list of {vfs_path, host_path, mode} tuples mapping a real host directory into the sandbox (see "Host mounts" below). mode is :read_only or :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 the curl/wget/http builtins may reach, or :all to 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 (default true; see "Network access").
  • :builtins - a map of name => fun registering 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, default 30_000). Also bounds :virtual_fs back-calls.
  • :virtual_fs - a map of mount_path => backend mounting Elixir-backed filesystems whose reads and writes are serviced by your application (see "Virtual filesystem backends" below and ExBashkit.VirtualFs).
  • :python - true (or [name: ...]/[names: [...]]) to register a sandboxed python/python3 builtin that shares the session filesystem. Requires the optional :ex_monty dependency (see ExBashkit.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_ms under 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 code 0.
  • {:error, iodata} - failure; the iodata becomes stderr, exit code 1.
  • %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.

t()

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

fs_type()

@type fs_type() :: :file | :dir | :symlink

A filesystem entry's kind, as reported by stat/2 and list_dir/2.

limit_key()

@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.

mount_mode()

@type mount_mode() :: :read_only | :read_write

Access mode for a host directory mount.

option()

@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()}

t()

@opaque t()

virtual_fs_spec()

@type virtual_fs_spec() :: (map() -> term()) | module() | {module(), term()}

A virtual-filesystem backend for one mount: a 1-arity function, a module implementing ExBashkit.VirtualFs, or {module, arg}.

Functions

exec(session, script)

@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_dir(session, path)

@spec list_dir(t(), Path.t()) ::
  {:ok, [{String.t(), fs_type()}]} | {:error, String.t()}

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}]}

mkdir(session, path, opts \\ [])

@spec mkdir(t(), Path.t(), keyword()) :: :ok | {:error, String.t()}

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 (default false).

new(opts \\ [])

@spec new([option()]) :: t()

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_file(session, path)

@spec read_file(t(), Path.t()) :: {:ok, binary()} | {:error, String.t()}

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(session, path, opts \\ [])

@spec remove(t(), Path.t(), keyword()) :: :ok | {:error, String.t()}

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 (default false).

rename(session, from, to)

@spec rename(t(), Path.t(), Path.t()) :: :ok | {:error, String.t()}

Rename/move from to to within the session's filesystem.

Returns :ok or {:error, message}.

restore(session, data, opts \\ [])

@spec restore(t(), binary(), keyword()) :: {:ok, t()} | {:error, String.t()}

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 (see snapshot/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"

snapshot(session, opts \\ [])

@spec snapshot(
  t(),
  keyword()
) :: {:ok, binary()} | {:error, String.t()}

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 — when true, capture shell state only (skip VFS contents). Default false.
  • :exclude_functions — when true, skip shell functions (avoids cloning AST-backed state). Default false.

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

stat(session, path)

@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_file(session, path, content)

@spec write_file(t(), Path.t(), iodata()) :: :ok | {:error, String.t()}

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"