JustBash (JustBash v0.4.0)

View Source

A simulated bash environment with virtual filesystem.

JustBash provides a sandboxed bash-like shell environment for Elixir applications. It's designed for AI agents and other use cases that need secure bash execution.

Basic Usage

bash = JustBash.new()
result = JustBash.exec(bash, "echo 'Hello World'")
IO.puts(result.stdout)  # "Hello World\n"

Features

  • In-memory virtual filesystem
  • Core bash commands (echo, cat, ls, etc.)
  • Shell features: pipes, redirections, variables
  • Control flow: if, for, while, case
  • Functions and local variables

Security Model

JustBash treats shell code as untrusted and sandboxes it in memory. Custom commands passed via :commands are trusted host-side extensions supplied by the library caller, and JustBash does not sandbox them or provide safety guarantees for them.

  • All execution happens in memory
  • No access to the real filesystem by default
  • No network access by default
  • Custom commands are outside the sandbox and can bypass filesystem and network restrictions
  • Execution limits to prevent infinite loops

Sigil

Use the ~b sigil for inline bash execution:

import JustBash.Sigil

# Execute and get result map
result = ~b"echo hello"
result.stdout  # "hello\n"

# With modifiers
~b"echo hello"t  # "hello" (trimmed)
~b"echo hello"s  # "hello\n" (stdout only)
~b"exit 42"e     # 42 (exit code)
~b"echo hi"x     # "hi\n" (raises on non-zero exit)

# With interpolation
name = "world"
~b"echo hello #{name}"t  # "hello world"

Summary

Types

Execution statistics from the most recent exec/2 call.

t()

Functions

Execute a bash command in the environment.

Execute a bash command, raising instead of containing failures.

Execute a bash script from a path in the virtual filesystem.

Format a bash script into a consistent, readable format.

Format a bash script, raising on parse errors.

Read a host-side value from the JustBash struct's context map.

Mount an additional VFS.Mountable backend into the environment's filesystem at mountpoint.

Create a new JustBash environment with default configuration.

Parse a bash script and return the AST.

Store a host-side value on the JustBash struct's context map under an atom key.

Returns execution statistics from the most recent exec/2 call.

Tokenize a bash script and return the tokens.

Tokenize a bash command string, raising on error.

Remove the mount at mountpoint from the environment's filesystem. No-op if nothing is mounted there.

Types

exec_result()

@type exec_result() :: %{
  stdout: String.t(),
  stderr: String.t(),
  exit_code: non_neg_integer(),
  env: map()
}

network_config()

@type network_config() :: %{
  enabled: boolean(),
  allow_list: [String.t()] | :all,
  allow_insecure: boolean()
}

shell_opts()

@type shell_opts() :: %{errexit: boolean(), nounset: boolean(), pipefail: boolean()}

stats()

@type stats() :: %{
  steps: non_neg_integer(),
  output_bytes: non_neg_integer(),
  max_exec_depth: non_neg_integer()
}

Execution statistics from the most recent exec/2 call.

t()

@type t() :: %JustBash{
  commands: %{required(String.t()) => module()},
  context: map(),
  cwd: String.t(),
  databases: map(),
  env: map(),
  exit_code: non_neg_integer(),
  fs: JustBash.FS.t(),
  functions: map(),
  http_client: term(),
  interpreter: JustBash.Interpreter.State.t(),
  jq_module_paths: [String.t()],
  last_exit_code: non_neg_integer(),
  limits: JustBash.Limit.t() | nil,
  max_call_depth: pos_integer(),
  max_iterations: pos_integer(),
  network: network_config(),
  shell_opts: shell_opts()
}

Functions

exec(bash, command)

@spec exec(t(), String.t()) :: {exec_result(), t()}

Execute a bash command in the environment.

Returns a tuple of {result, updated_bash} where result contains stdout, stderr, exit_code, and the final env.

Examples

bash = JustBash.new()
{result, _bash} = JustBash.exec(bash, "echo hello")
result.stdout  # "hello\n"
result.exit_code  # 0

{result, _bash} = JustBash.exec(bash, "cat nonexistent")
result.stderr  # "cat: nonexistent: No such file or directory\n"
result.exit_code  # 1

exec!(bash, command)

@spec exec!(t(), String.t()) :: {exec_result(), t()}

Execute a bash command, raising instead of containing failures.

Same limits as exec/2 — including :max_wall_ms, which is armed here too, so a script that spins is bounded by both entry points.

It differs from exec/2 in what it does with a failure it cannot express as a shell result. It raises a RuntimeError on a parse error, where exec/2 returns exit 2 with a bash: syntax error: ... diagnostic; and it propagates any exception that escapes the interpreter's own containment — the EXIT trap and telemetry are the paths that can still do that — where exec/2 reports bash: internal error (...).

A command that crashes and a raise from inside the statement loop are contained by the interpreter itself, so both entry points get a shell result for those. A host running untrusted script text should still prefer exec/2, which is the documented trust boundary.

Examples

bash = JustBash.new()
{result, _bash} = JustBash.exec!(bash, "echo hello")

exec_file(bash, path)

@spec exec_file(t(), String.t()) :: {exec_result(), t()}

Execute a bash script from a path in the virtual filesystem.

Reads the script from the sandbox's virtual filesystem and executes it. The script must exist in bash.fs — no real filesystem access occurs.

Examples

bash = JustBash.new(files: %{"/script.sh" => "echo hello"})
{result, bash} = JustBash.exec_file(bash, "/script.sh")

format(input, opts \\ [])

@spec format(
  String.t(),
  keyword()
) :: {:ok, String.t()} | {:error, JustBash.Parser.ParseError.t()}

Format a bash script into a consistent, readable format.

Parses the input script and outputs it with consistent formatting:

  • Consistent indentation for control structures
  • Normalized whitespace
  • Proper line breaks

Options

  • :indent - Indentation string (default: " " - two spaces)

Examples

JustBash.format("if true;then echo yes;fi")
# {:ok, "if true; then\n  echo yes\nfi"}

JustBash.format("echo   hello    world")
# {:ok, "echo hello world"}

JustBash.format("for i in 1 2 3;do echo $i;done")
# {:ok, "for i in 1 2 3; do\n  echo $i\ndone"}

format!(input, opts \\ [])

@spec format!(
  String.t(),
  keyword()
) :: String.t()

Format a bash script, raising on parse errors.

Examples

JustBash.format!("echo hello")
# "echo hello"

get_context(just_bash, key, default \\ nil)

@spec get_context(t(), atom(), term()) :: term()

Read a host-side value from the JustBash struct's context map.

Returns default (which itself defaults to nil) when the key is absent. Keys must be atoms. This reads the same map seeded by the :context option of new/1 and written by put_context/3.

Examples

bash = JustBash.new() |> JustBash.put_context(:request_id, "abc123")
JustBash.get_context(bash, :request_id)
#=> "abc123"

JustBash.get_context(bash, :missing)
#=> nil

JustBash.get_context(bash, :missing, :fallback)
#=> :fallback

mount(bash, mountpoint, backend)

@spec mount(t(), String.t(), VFS.Mountable.t()) :: t()

Mount an additional VFS.Mountable backend into the environment's filesystem at mountpoint.

The default filesystem is a %VFS{} mount table with JustBash's in-memory backend at /; additional backends (an exgit repository, a VFS.Memory scratch space, any caller-provided VFS.Mountable) mount alongside it and every bash command sees them transparently. Mount resolution is longest-prefix, so a mount at /repo shadows the root backend for paths under /repo.

Backends that don't support an operation refuse it with a structured error (e.g. writing to a read-only mount fails with "Read-only file system"; creating a symlink on a backend without symlinks fails with "Operation not supported").

Examples

bash = JustBash.new()
bash = JustBash.mount(bash, "/mnt", VFS.Memory.new(%{"/data.csv" => "a,b\n"}))
{result, _bash} = JustBash.exec(bash, "cat /mnt/data.csv")

new(opts \\ [])

@spec new(keyword()) :: t()

Create a new JustBash environment with default configuration.

Options

  • :files - Initial files as a map of path => content. Raises ArgumentError if the map cannot exist as a filesystem — one path running through another (%{"/m/j" => "x", "/m/j/a.md" => "y"}, where /m/j would have to be both a file and a directory), or a path colliding with one of the default directories.
  • :env - Initial environment variables
  • :cwd - Starting working directory (default: "/home/user")
  • :commands - Custom commands as a map of name => module implementing JustBash.Commands.Command. Custom commands are trusted host-side extensions supplied by the library caller. They run arbitrary Elixir code, are not constrained by the virtual filesystem or :network sandbox, and are outside JustBash's safety guarantees. Registration keys must be declared in the module's names/0, and aliases from names/0 are registered automatically. Custom commands override regular builtins but are overridden by shell functions. Protected stateful builtins such as cd and export cannot be overridden. Dispatch order: shell functions > custom commands > builtins.
  • :context - Optional map of caller data for custom commands. Stored on the JustBash struct as context and readable inside any custom command as bash.context. Defaults to %{}. Not used by builtins or the interpreter; only host-defined custom commands should read it. To add or update entries after construction, use put_context/3 and get_context/3.
  • :network - Network configuration map with:
    • :enabled - Whether network access is allowed (default: false)
    • :allow_list - Allowed hosts/patterns. Use :all to allow all hosts, or a list of hostname patterns (e.g. ["api.example.com", "*.github.com"]). Empty list [] blocks all requests. (default: [] = all requests blocked when enabled)
    • :allow_insecure - Whether plain HTTP is permitted. When false (default), only https:// URLs are allowed. Scripts cannot override this — it is a caller-level control.
  • :http_client - Module implementing the HTTP client behaviour (default: uses Req)
  • :max_iterations - Maximum iterations for while/until loops before they are forcibly stopped. Prevents runaway loops from untrusted scripts (default: 10_000)
  • :max_call_depth - Maximum shell function call depth before recursion is forcibly stopped. Prevents unbounded recursion from consuming all available memory (default: 1_000)
  • :limits - Resource limits for production safety. Accepts a preset atom (:default, :strict, :relaxed), a keyword list of overrides, or false to disable. Default: :default. See JustBash.Limit for available keys.
  • :jq_module_paths - List of virtual filesystem paths to search for jq modules when using import/include directives (default: [])

Examples

bash = JustBash.new()
bash = JustBash.new(files: %{"/data/file.txt" => "content"})
bash = JustBash.new(env: %{"MY_VAR" => "value"}, cwd: "/app")
bash = JustBash.new(commands: %{"python" => MyPythonCommand})
bash = JustBash.new(network: %{enabled: true})
bash = JustBash.new(network: %{enabled: true, allow_list: ["api.example.com", "*.github.com"]})

# Custom HTTP client for testing:
bash = JustBash.new(network: %{enabled: true}, http_client: MyTestHttpClient)

# Pass data to custom commands via bash.context:
bash = JustBash.new(context: %{user_id: 42}, commands: %{"my_cmd" => MyCommand})

parse(input)

@spec parse(String.t()) ::
  {:ok, JustBash.AST.Script.t()} | {:error, JustBash.Parser.ParseError.t()}

Parse a bash script and return the AST.

Useful for debugging or analyzing scripts without executing them.

Examples

{:ok, ast} = JustBash.parse("echo hello")
{:error, error} = JustBash.parse("echo 'unterminated")

put_context(bash, key, value)

@spec put_context(t(), atom(), term()) :: t()

Store a host-side value on the JustBash struct's context map under an atom key.

This is the post-construction counterpart to the :context option of new/1, modeled on Plug.Conn.put_private/3. The context map is reserved for the library caller to stash arbitrary data that travels with the struct. Keys must be atoms; values may be any term. Builtins and the interpreter never read context — only host-defined custom commands should, via bash.context or get_context/3.

Use the :context option to seed values at construction, and put_context/3 to add or update them afterward — both target the same context map.

Examples

bash = JustBash.new()
bash = JustBash.put_context(bash, :request_id, "abc123")
bash.context
#=> %{request_id: "abc123"}

# Construction-time seeding and post-construction updates compose:
bash =
  JustBash.new(context: %{tenant: "acme"})
  |> JustBash.put_context(:request_id, "abc123")
bash.context
#=> %{tenant: "acme", request_id: "abc123"}

stats(just_bash)

@spec stats(t()) :: stats()

Returns execution statistics from the most recent exec/2 call.

Useful for observing computational cost without enforcing limits — for example, as a reward signal in reinforcement learning to prefer simpler programs.

Counters reset at the start of each top-level exec/2 call, so stats always reflect the most recent execution.

Examples

bash = JustBash.new()
{_result, bash} = JustBash.exec(bash, "for i in 1 2 3; do echo $i; done")
JustBash.stats(bash)
#=> %{steps: 12, output_bytes: 6, max_exec_depth: 1}

tokenize(input)

@spec tokenize(String.t()) ::
  {:ok, [JustBash.Parser.Lexer.Token.t()]}
  | {:error, JustBash.Parser.Lexer.Error.t()}

Tokenize a bash script and return the tokens.

Useful for debugging the lexer.

Examples

{:ok, tokens} = JustBash.tokenize("echo hello")
# [%Token{type: :name, value: "echo", ...}, %Token{type: :name, value: "hello", ...}, ...]

tokenize!(input)

@spec tokenize!(String.t()) :: [JustBash.Parser.Lexer.Token.t()]

Tokenize a bash command string, raising on error.

umount(bash, mountpoint)

@spec umount(t(), String.t()) :: t()

Remove the mount at mountpoint from the environment's filesystem. No-op if nothing is mounted there.