This vignette covers sessions you keep open, what happens to output
and errors, the audit log, and session pools. If you’re new to securer,
start with vignette("quickstart").
Persistent sessions
SecureSession keeps one child R process running across
many $execute() calls. Variables you create in one call are
still there in the next:
library(securer)
tools <- list(
securer_tool("add", "Add two numbers",
fn = function(a, b) a + b,
args = list(a = "numeric", b = "numeric"))
)
session <- SecureSession$new(tools = tools, sandbox = TRUE)
session$execute("x <- add(10, 20)")
session$execute("x * 2")
#> [1] 60
session$close()Call $close() when you’re done. Or use
with_secure_session(), which closes the session for you,
even if the code fails:
result <- with_secure_session(function(session) {
session$execute("x <- 10")
session$execute("x * 2")
}, sandbox = FALSE)Limits for agents
A few options are there mainly for code written by an LLM.
$execute() refuses code longer than
max_code_length characters (100,000 by default). In
$new(), max_executions caps how many times a
session can run code, and pre_execute_hook is a function
that sees the code first and can block it by returning
FALSE. sanitize_errors = TRUE removes file
paths, process IDs, and host names from error messages before you see
them, so they don’t end up back in the model’s context.
session <- SecureSession$new(
max_executions = 100,
pre_execute_hook = function(code) {
# Block code that mentions system()
!grepl("system\\(", code)
},
sanitize_errors = TRUE
)vignette("security-model") explains how these fit with
the sandbox.
Execution timeouts
$execute() takes a timeout in seconds (30
by default). When time runs out, securer kills the child process and
starts a new one, so the session still works:
session <- SecureSession$new()
session$execute("Sys.sleep(60)", timeout = 5)
#> Error: Execution timed out after 5 seconds
session$is_alive()
#> [1] TRUE
session$close()Error handling
An error in the sandboxed code or in a tool comes back to you as an ordinary R error:
Streaming output
To see output while the code is still running, pass an
output_handler. It gets each line as it’s printed:
session <- SecureSession$new()
session$execute(
'for (i in 1:5) cat("Step", i, "\\n")',
output_handler = function(line) message("[child] ", line)
)
session$close()Whether or not you pass a handler, the printed output is also
attached to the result as attr(result, "output").
Output and tool call limits
max_output_lines and max_tool_calls limit a
single $execute() call:
session <- SecureSession$new()
# Cap accumulated output lines
result <- session$execute(
'for (i in 1:1000) cat("line", i, "\\n")',
max_output_lines = 100
)
# Cap tool calls in one execution
session$execute("for (i in 1:10) add(i, i)", max_tool_calls = 5)
#> Error: Maximum tool calls (5) exceeded
session$close()Session lifecycle
$restart() replaces the child process with a fresh one.
$is_alive() tells you whether the child is running:
session <- SecureSession$new(sandbox = FALSE)
session$execute("x <- 42")
session$restart()
# State is gone after restart
session$execute("exists('x')")
#> [1] FALSE
session$is_alive()
#> [1] TRUE
session$close()Audit logging
Pass a file path as audit_log and the session writes one
JSON object per line for each thing that happens:
session <- SecureSession$new(audit_log = "securer-audit.jsonl")
session$execute("1 + 1")
session$close()Every line has timestamp, event, and
session_id, plus fields that depend on the event:
| Event | Extra fields |
|---|---|
session_start |
sandbox, pid
|
session_close, session_restart
|
none |
execute_start |
code |
execute_complete |
elapsed |
execute_error |
error, elapsed
|
execute_timeout |
timeout_secs |
tool_call |
tool, args
|
tool_result |
tool, error, result_summary,
elapsed_secs
|
A tool_call line looks like this:
{"timestamp":"2024-01-15T10:30:00.000Z","event":"tool_call","session_id":"sess_abc123","tool":"add","args":{"a":1,"b":2}}Only you can read or write the log file (permissions
0600). Code longer than 10,000 characters is cut short in
the log.
Session pooling
Why use a pool
A new SecureSession has to start R, set up the sandbox,
and open the socket. That takes half a second to a second. In a Plumber
API or a Shiny app with many users, paying that on every request adds
up. A pool starts a fixed number of sessions once and hands them out as
requests come in.
How a pool works
Initialize Acquire Execute Release
+-------------+ +-------------+ +-------------+ +-------------+
| Pre-warm N |-->| Caller gets |-->| Run code in |-->| Return to |
| sessions at | | idle session| | acquired | | idle pool |
| startup | | from pool | | session | | (or restart |
| | | | | | | if dead) |
+-------------+ +-------------+ +-------------+ +-------------+
SecureSessionPool starts its sessions when you create
it:
pool <- SecureSessionPool$new(size = 4, sandbox = TRUE)
pool$execute("1 + 1")
#> [1] 2
pool$status() # returns list(total, busy, idle, dead)
pool$close()Sessions in a pool keep their variables between uses. If different
users share the pool, set reset_between_uses = TRUE so each
session restarts after every call and one caller can’t see another’s
data:
pool <- SecureSessionPool$new(
size = 2,
sandbox = FALSE,
reset_between_uses = TRUE
)
pool$execute("x <- 99")
pool$execute("exists('x')")
#> [1] FALSE
pool$close()If a session has died, the pool restarts it before handing it out.
The pool isn’t thread-safe. With parallel or
future, give each worker its own pool.