Skip to main content

lib/pi/eval/sandbox.ex

defmodule Pi.Eval.Sandbox do
  @moduledoc """
  Restricted Elixir evaluation for untrusted bridge surfaces.

  This module uses the optional `:dune` dependency when available. It is intended
  for external/user-entered snippets, not for the trusted project introspection
  path exposed by `Pi.Eval.run/2`.
  """

  @default_timeout_ms 5_000
  @default_max_reductions 50_000
  @default_max_heap_size 100_000

  @type result ::
          {:ok, %{value: term(), inspected: String.t(), stdio: String.t()}}
          | {:error, String.t()}
          | {:error, :unavailable}

  @spec available?() :: boolean()
  def available?, do: Code.ensure_loaded?(Dune)

  @spec eval(String.t(), keyword()) :: result()
  def eval(code, opts \\ []) when is_binary(code) do
    if available?() do
      do_eval(code, opts)
    else
      {:error, :unavailable}
    end
  end

  defp do_eval(code, opts) do
    timeout = Keyword.get(opts, :timeout, @default_timeout_ms)
    max_reductions = Keyword.get(opts, :max_reductions, @default_max_reductions)
    max_heap_size = Keyword.get(opts, :max_heap_size, @default_max_heap_size)
    allowlist = Keyword.get(opts, :allowlist, configured_allowlist())

    dune_opts =
      [
        timeout: timeout,
        max_reductions: max_reductions,
        max_heap_size: max_heap_size
      ]
      |> maybe_put(:allowlist, allowlist)

    case Dune.eval_string(code, dune_opts) do
      %{__struct__: Dune.Success, value: value, inspected: inspected, stdio: stdio} ->
        {:ok, %{value: value, inspected: inspected, stdio: stdio}}

      %{__struct__: Dune.Failure, message: message} ->
        {:error, message}
    end
  end

  defp configured_allowlist do
    Application.get_env(:pi_bridge, :sandbox_allowlist)
  end

  defp maybe_put(opts, _key, nil), do: opts
  defp maybe_put(opts, key, value), do: Keyword.put(opts, key, value)
end