ADR-0009: Local executor without sandboxing

View Source

Status

Accepted

Context

Conjure must execute commands and file operations from skill instructions. The simplest implementation uses Elixir's System.cmd/3 to run commands directly on the host:

System.cmd("bash", ["-c", command], opts)

This provides no isolation between skill execution and the host system. A malicious or buggy skill could:

  • Read/write any file accessible to the BEAM process
  • Execute arbitrary commands
  • Access network resources
  • Consume unlimited resources
  • Access environment variables and secrets

For production deployments, this is unacceptable. However, sandboxing adds complexity:

  • Docker requires daemon installation and permissions
  • Firecracker/gVisor require specialized setup
  • seccomp/AppArmor require Linux-specific configuration
  • All isolation mechanisms add latency

During development and testing, developers often:

  • Run trusted skills they wrote themselves
  • Need fast iteration without container overhead
  • Work on systems without Docker (Windows without WSL, restricted environments)
  • Debug skill behavior with direct filesystem access

Decision

We will provide Conjure.Executor.Local as an unsandboxed executor for development use only.

The module will:

  1. Execute commands directly via System.cmd/3
  2. Perform file operations via Elixir's File module
  3. Include prominent documentation warnings
  4. Log warnings when used
  5. Be explicitly named to indicate its nature
defmodule Conjure.Executor.Local do
  @moduledoc """
  Local execution backend using System.cmd.

  ## Security Warning

  This executor provides NO SANDBOXING. Commands and file operations
  execute with the same permissions as the BEAM process.

  **DO NOT USE IN PRODUCTION** unless you fully trust all loaded skills
  and accept the security implications.

  For production deployments, use `Conjure.Executor.Docker` or implement
  a custom executor with appropriate isolation.
  """

  require Logger

  @behaviour Conjure.Executor

  @impl true
  def init(context) do
    Logger.warning(
      "[Conjure] Using Local executor - NO SANDBOXING. " <>
      "Do not use in production with untrusted skills."
    )
    {:ok, context}
  end

  @impl true
  def bash(command, context) do
    System.cmd("bash", ["-c", command],
      cd: context.working_directory,
      stderr_to_stdout: true
    )
    |> handle_result()
  end

  # ... other implementations
end

The Local executor will be the default only if no other executor is configured, ensuring users make an explicit choice for production.

Consequences

Positive

  • Zero setup required for development
  • Fast execution (no container overhead)
  • Works on any platform with Elixir
  • Simple debugging (direct filesystem access)
  • No external dependencies
  • Enables rapid skill development iteration

Negative

  • Security risk if misused in production
  • No resource limits (memory, CPU, time)
  • No filesystem isolation
  • No network isolation
  • Users may forget to switch executors for production

Neutral

  • Warnings logged on every initialization
  • Documentation emphasizes security implications
  • Production configuration should explicitly set Docker executor

Mitigations

While we accept the security tradeoffs for development, we implement some basic protections:

1. Path Validation

def view(path, context, _opts) do
  with {:ok, safe_path} <- validate_path(path, context.allowed_paths) do
    File.read(safe_path)
  end
end

defp validate_path(path, allowed_paths) do
  normalized = Path.expand(path)

  if Enum.any?(allowed_paths, &String.starts_with?(normalized, Path.expand(&1))) do
    {:ok, normalized}
  else
    {:error, :path_not_allowed}
  end
end

2. Timeout Enforcement

def bash(command, context) do
  task = Task.async(fn ->
    System.cmd("bash", ["-c", command], opts)
  end)

  case Task.yield(task, context.timeout) || Task.shutdown(task) do
    {:ok, result} -> handle_result(result)
    nil -> {:error, :timeout}
  end
end

3. Configuration Warnings

# In production config
config :conjure, executor: Conjure.Executor.Docker

# If Local is used in prod, log error on startup
def check_executor_safety do
  if Mix.env() == :prod and executor() == Conjure.Executor.Local do
    Logger.error(
      "[Conjure] Local executor used in production! " <>
      "This is a security risk. Set `config :conjure, executor: Conjure.Executor.Docker`"
    )
  end
end

Alternatives Considered

No local executor

Require Docker or other sandbox for all execution. Rejected because:

  • Blocks development on systems without Docker
  • Adds friction for getting started
  • Hurts adoption

Local with seccomp/Landlock

Use Linux security modules for some isolation. Rejected because:

  • Linux-only
  • Complex configuration
  • Partial protection gives false confidence

Local with user namespaces

Run as unprivileged user. Rejected because:

  • Linux-only
  • Still allows network access
  • Complex setup

Warn once only

Only log warning on first use. Rejected because:

  • Easy to miss in logs
  • Subsequent sessions won't see warning
  • Per-init warning is minimal overhead

References