JustBash (JustBash v0.3.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 on parse errors.

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.

Create a new JustBash environment with default configuration.

Parse a bash script and return the AST.

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.

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.InMemoryFs.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 on parse errors.

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"

new(opts \\ [])

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

Create a new JustBash environment with default configuration.

Options

  • :files - Initial files as a map of path => content
  • :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.
  • :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")

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.