ExMonty.Sandbox behaviour (ExMonty v0.4.2)

Copy Markdown View Source

High-level handler for interactive Python execution.

The Sandbox automates the start/resume loop by dispatching name lookups, function calls, dataclass method calls, and OS calls to handler callbacks.

External function names are auto-detected at runtime: when Python code references an undefined name, the sandbox checks the :functions map and :handler module to decide whether to provide a function object or raise NameError. No upfront declaration of external functions is needed.

Dataclass method calls are dispatched through the same function handlers as regular external function calls. The method name is looked up in the :functions map or dispatched to handle_function/3 in the :handler module.

Module-based Handler

defmodule MyHandler do
  @behaviour ExMonty.Sandbox

  @impl true
  def handle_function("fetch", [url], _kwargs) do
    case Req.get(url) do
      {:ok, resp} -> {:ok, resp.body}
      {:error, _} -> {:error, :runtime_error, "fetch failed"}
    end
  end

  @impl true
  def handle_os(:read_text, [{:path, path}], _kwargs) do
    case File.read(path) do
      {:ok, content} -> {:ok, content}
      {:error, reason} -> {:error, :file_not_found_error, to_string(reason)}
    end
  end
end

{:ok, result, output} = ExMonty.Sandbox.run(code,
  inputs: %{"url" => "https://example.com"},
  handler: MyHandler,
  limits: %{max_duration_secs: 5.0}
)

Function Map Handler

{:ok, result, output} = ExMonty.Sandbox.run(code,
  inputs: %{"x" => 1},
  functions: %{
    "fetch" => fn [url], _kwargs -> {:ok, "response"} end
  }
)

Clock Handlers

date.today() and datetime.now() are surfaced as :date_today and :datetime_now os calls. Provide handlers in the :os map to control what "now" means (deterministic tests, time-travel, request timestamps, etc.):

now = DateTime.utc_now()

{:ok, _result, _} = ExMonty.Sandbox.run(
  "from datetime import datetime, timezone\ndatetime.now(tz=timezone.utc).year",
  os: %{
    datetime_now: fn _args, _kwargs ->
      {:ok, {:datetime, %{
        year: now.year, month: now.month, day: now.day,
        hour: now.hour, minute: now.minute, second: now.second,
        microsecond: elem(now.microsecond, 0),
        offset_seconds: 0, tz_name: nil
      }}}
    end
  }
)

Host Filesystem Mounts

For sandboxed access to real host directories, pass an ExMonty.Mount as the :mounts option:

mounts =
  ExMonty.Mount.new!()
  |> ExMonty.Mount.add!("/data", "/var/lib/myapp/data", :read_only)

ExMonty.Sandbox.run(code, mounts: mounts)

:mounts composes with :os — mounts handle filesystem calls, the :os map handles non-filesystem fallbacks (:getenv, :datetime_now, etc.). See ExMonty.Mount for mode semantics, lease lifecycle, and security guarantees.

Pseudo Filesystem

Pass an ExMonty.PseudoFS as the :os option for sandboxed filesystem access:

fs = ExMonty.PseudoFS.new()
  |> ExMonty.PseudoFS.put_file("/data/input.txt", "hello world")

{:ok, result, output} = ExMonty.Sandbox.run(
  "from pathlib import Path; Path('/data/input.txt').read_text()",
  os: fs
)

Summary

Callbacks

Called when Python code invokes an external function.

Called when Python code references an undefined name.

Called when Python code performs an OS/filesystem operation.

Functions

Compiles and runs Python code with automatic handler dispatch.

Types

handler_result()

@type handler_result() :: {:ok, term()} | {:error, atom(), String.t()}

Callbacks

handle_function(name, args, kwargs)

@callback handle_function(name :: String.t(), args :: list(), kwargs :: map()) ::
  handler_result()

Called when Python code invokes an external function.

Should return {:ok, value} on success or {:error, exc_type, message} on failure.

handle_name_lookup(name)

(optional)
@callback handle_name_lookup(name :: String.t()) :: {:ok, term()} | :undefined

Called when Python code references an undefined name.

Return {:ok, value} to provide the value, or :undefined to raise NameError. For external functions, return {:ok, {:function, name}}.

handle_os(function, args, kwargs)

(optional)
@callback handle_os(function :: atom(), args :: list(), kwargs :: map()) ::
  handler_result()

Called when Python code performs an OS/filesystem operation.

Optional — defaults to returning an error for all OS calls.

Functions

run(code, opts \\ [])

@spec run(
  String.t(),
  keyword()
) :: {:ok, term(), String.t()} | {:error, term()}

Compiles and runs Python code with automatic handler dispatch.

Options

  • :inputs - map of input variable names to values (default: %{})
  • :handler - module implementing ExMonty.Sandbox behaviour
  • :functions - map of function name strings to handler fns (args, kwargs -> result)
  • :os - OS call handler. Can be:
    • An ExMonty.PseudoFS struct for in-memory filesystem
    • A map of %{atom => fn args, kwargs -> result} for per-function handlers
  • :mounts - an ExMonty.Mount for host filesystem access. Composes with :os (mounts handle FS calls; :os map handles non-FS fallback calls like :getenv or :datetime_now). Unmounted paths raise PermissionError.
  • :limits - resource limits map (default: nil)
  • :script_name - script name for tracebacks (default: "main.py")

Either :handler or :functions must be provided for external function calls. OS calls require either :os or handle_os/3 in the :handler module.

Examples

{:ok, result, output} = ExMonty.Sandbox.run(
  "result = fetch('https://example.com')",
  handler: MyHandler
)

{:ok, result, output} = ExMonty.Sandbox.run(
  "result = double(21)",
  functions: %{
    "double" => fn [x], _kwargs -> {:ok, x * 2} end
  }
)

# With pseudo filesystem
fs = ExMonty.PseudoFS.new()
  |> ExMonty.PseudoFS.put_file("/config.json", ~s({"key": "value"}))

{:ok, result, output} = ExMonty.Sandbox.run(
  ~s(from pathlib import Path; Path('/config.json').read_text()),
  os: fs
)