Current section
Files
Jump to
Current section
Files
lib/condukt/sandbox/virtual.ex
defmodule Condukt.Sandbox.Virtual do
@moduledoc """
Sandbox that runs against an in-memory virtual filesystem and a
Rust-implemented bash interpreter via the bashkit NIF.
No host process spawning by default. Host directories can be mounted into
the virtual filesystem at construction time via `:mounts`, or at runtime via
`Condukt.Sandbox.Virtual.Tools.Mount`.
## Initializing
{:ok, sandbox} = Condukt.Sandbox.new(Condukt.Sandbox.Virtual)
# Mount the host project at /workspace, read-only:
{:ok, sandbox} =
Condukt.Sandbox.new(Condukt.Sandbox.Virtual,
mounts: [{File.cwd!(), "/workspace", :readonly}]
)
## Notes
Each `exec/3` call is stateless: shell variables, `cd`, and `export` do not
persist across calls. This matches `Sandbox.Local`'s contract and lets the
Bash tool behave identically in both sandboxes. For a stateful interactive
shell, use `Condukt.Sandbox.Virtual.Tools.Shell` (planned).
"""
@behaviour Condukt.Sandbox
alias Condukt.Bashkit.NIF
alias Condukt.Sandbox
alias Condukt.Sandbox.Virtual.State
# ============================================================================
# Sandbox callbacks
# ============================================================================
# Bashkit's interpreter starts with this cwd. Used to reset between
# exec/3 calls so the sandbox stays stateless. Override with the
# `:cwd` init option if you mounted a workspace elsewhere.
@default_base_cwd "/home/user"
@impl Sandbox
def init(opts) do
with {:ok, mounts} <- normalize_mounts(Keyword.get(opts, :mounts, [])),
{:ok, session} <- start_nif_session(mounts) do
base_cwd = Keyword.get(opts, :cwd, @default_base_cwd)
{:ok, %State{session: session, base_cwd: base_cwd}}
end
end
defp start_nif_session(mounts) do
{:ok, NIF.new_session(mounts)}
catch
kind, reason -> {:error, format_caught(kind, reason)}
end
defp format_caught(_kind, reason) do
if is_exception(reason), do: Exception.message(reason), else: inspect(reason)
end
@impl Sandbox
def shutdown(%State{session: session}) do
_ = NIF.shutdown(session)
:ok
end
@impl Sandbox
def read_file(%State{session: session}, path) do
NIF.read_file(session, path)
end
@impl Sandbox
def write_file(%State{session: session}, path, content) do
with {:ok, :ok} <- NIF.write_file(session, path, content), do: :ok
end
@impl Sandbox
def edit_file(%State{session: session}, path, old_text, new_text) do
NIF.edit_file(session, path, old_text, new_text)
end
@impl Sandbox
def exec(%State{session: session, base_cwd: base_cwd}, command, opts) do
timeout = Keyword.get(opts, :timeout)
env = Keyword.get(opts, :env, [])
# Stateless exec: each call resets cwd to the sandbox's base, then
# optionally `cd`s into the per-call :cwd. This matches Sandbox.Local
# where each call starts fresh.
target_cwd = Keyword.get(opts, :cwd) || base_cwd
script =
case target_cwd do
nil -> command
cwd -> "cd #{shell_quote(cwd)} && #{command}"
end
|> prepend_env_exports(env)
NIF.exec(session, script, timeout)
end
@impl Sandbox
def glob(%State{session: session}, pattern, opts) do
NIF.glob(session, pattern, opts[:cwd])
end
@impl Sandbox
def grep(%State{session: session}, pattern, opts) do
NIF.grep(
session,
pattern,
opts[:path],
Keyword.get(opts, :case_sensitive, true),
opts[:glob]
)
end
@impl Sandbox
def mount(%State{session: session}, host_path, vfs_path) do
with {:ok, :ok} <- NIF.mount(session, host_path, vfs_path, :readwrite), do: :ok
end
# ============================================================================
# Helpers
# ============================================================================
defp normalize_mounts(mounts) when is_list(mounts) do
Enum.reduce_while(mounts, {:ok, []}, fn entry, {:ok, acc} ->
case normalize_mount(entry) do
{:ok, normalized} -> {:cont, {:ok, [normalized | acc]}}
{:error, _} = err -> {:halt, err}
end
end)
|> case do
{:ok, list} -> {:ok, Enum.reverse(list)}
err -> err
end
end
defp normalize_mounts(_), do: {:error, ":mounts must be a list of {host, vfs[, mode]}"}
defp normalize_mount({host, vfs}), do: {:ok, {to_string(host), to_string(vfs), :readwrite}}
defp normalize_mount({host, vfs, mode}) when mode in [:readonly, :readwrite],
do: {:ok, {to_string(host), to_string(vfs), mode}}
defp normalize_mount(other), do: {:error, "invalid mount spec: #{inspect(other)}"}
defp shell_quote(s) do
"'" <> String.replace(s, "'", "'\\''") <> "'"
end
defp prepend_env_exports(script, []), do: script
defp prepend_env_exports(script, env) do
case normalize_env(env) do
[] ->
script
normalized ->
exports = Enum.map_join(normalized, "\n", fn {key, value} -> "export #{key}=#{shell_quote(value)}" end)
exports <> "\n" <> script
end
end
defp normalize_env(env) when is_map(env) do
env
|> Map.new(fn {key, value} -> {to_string(key), to_string(value)} end)
|> Enum.filter(&valid_env?/1)
end
defp normalize_env(env) when is_list(env) do
env
|> Enum.map(fn {key, value} -> {to_string(key), to_string(value)} end)
|> Enum.filter(&valid_env?/1)
end
defp normalize_env(_), do: []
defp valid_env?({key, _value}), do: Regex.match?(~r/^[A-Za-z_][A-Za-z0-9_]*$/, key)
end