Skip to contents

Like LLMR::llm_tool(), but the tool runs under confinement: a wall-clock timeout, a cap on result size, and auditing of where it writes. Each invocation hashes the input files it was handed and the output files it produced, so a tool that touches the filesystem leaves an auditable trail. The returned object is an ordinary llmr_tool, so it passes to agent() and the tool loop exactly as a plain tool does.

Usage

sandbox_tool(
  fn,
  name = NULL,
  description = NULL,
  parameters = NULL,
  required = NULL,
  mode = c("read_only", "tempdir", "container"),
  timeout_s = 30,
  max_bytes = 1e+06,
  allow_paths = NULL,
  env = "minimal",
  executor = NULL
)

Arguments

fn

The function to expose, or an existing llmr_tool to confine. When an llmr_tool is given, its own function, name, description, and schema are reused and the remaining name/description/parameters/required arguments are ignored.

name

Tool name shown to the model. Defaults to "sandboxed_tool".

description

One or two sentences for the model. Defaults to "A sandboxed tool.".

parameters

A named list of JSON-Schema properties, or a full schema object (as in LLMR::llm_tool()).

required

Character vector of required argument names.

mode

The confinement regime. "read_only": the child runs with its working directory set to a scratch directory; relative writes land there and are hashed and checked against allow_paths, and any reported write outside allow_paths is a violation (the default executor cannot intercept absolute-path writes; see Details). "tempdir": the scratch directory is the sanctioned writable location and is always permitted; reported writes elsewhere are violations. "container": confinement is delegated entirely to a supplied executor, which is the way to obtain a hard filesystem boundary.

timeout_s

Wall-clock limit per call, in seconds (default 30). The default executor kills the child process when it elapses; the call then reports a "timeout" status.

max_bytes

Maximum result size in bytes; a larger result (or captured output) is truncated and flagged. Default 1e6.

allow_paths

Character vector of directories outside which a reported written file is a violation. The scratch working directory is always permitted (in "tempdir" mode). The check applies only to files the executor reports; with the default executor that means files written under the scratch directory, since absolute-path writes elsewhere are not seen (see Details). Default NULL (only the scratch directory is allowed).

env

How much of the ambient environment the child sees. Recorded for provenance and passed to the executor; the default executor treats "minimal" as a hint and does not inherit the parent's global objects.

executor

A function (fn, args, workdir, timeout_s) implementing the contract above. When NULL, a default child-process executor is built for "read_only"/"tempdir" (requires callr; without it the function runs in-process with a one-time warning that confinement is weak), and "container" mode raises an error asking for an executor.

Value

An llmr_tool carrying a "governance" attribute whose sandbox element records the mode and allow_paths. Each call returns the tool's result string with a "sandbox" attribute recording the mode, duration, byte count, input and output file hashes, and status.

Details

Confinement is carried out by an executor, a function that runs the user function in a bounded place and reports what it produced. The default executor runs the function in a child R process (via the callr package), which is killed when the timeout elapses; mode = "container" requires an executor to be supplied, because the package does not assume any particular container runtime. Supplying executor directly is also how the tool is tested offline: a fake executor returns a canned result and file list, so the size, timeout, and path checks can be exercised without spawning anything.

What the default executor actually guarantees. The default child-process executor runs the user function with its working directory set to a fresh scratch directory, then snapshots that directory before and after the call to hash every file written under it. Relative writes therefore land in the scratch directory and are hashed and checked against allow_paths. The default executor does not establish a hard filesystem boundary: an absolute-path write outside the scratch directory (e.g. to /tmp/out or $HOME/out) happens in the same operating-system namespace as the parent and is not intercepted, so llmragent_sandbox_violation cannot fire for a write the executor never sees. The guarantee is therefore "confine and audit writes within the scratch working directory", not "block absolute-path writes". To enforce a real boundary against arbitrary absolute paths, supply a mode = "container" executor (or an OS-level sandbox) that runs the function in a confined namespace and reports the files it wrote; the allow_paths check then applies to whatever that executor reports.

The executor contract is executor(fn, args, workdir, timeout_s) returning a list with elements stdout (character), result (the value, or NULL), files (a named character vector mapping written paths to content hashes, or a bare character vector of written paths), status (one of "ok", "timeout", "error"), and error (a message, or NA).

Examples

if (FALSE) { # \dontrun{
# A tool that runs in a killed-on-timeout child R process.
slow <- sandbox_tool(
  function(n) { Sys.sleep(n); "done" },
  name = "wait", description = "Sleeps for n seconds, then returns.",
  parameters = list(n = list(type = "number")),
  mode = "tempdir", timeout_s = 2
)
slow$fn(n = 10)  # reports a timeout rather than blocking

# Offline: an injected executor needs no process at all.
double <- sandbox_tool(
  function(x) x * 2, name = "double", description = "Doubles x.",
  parameters = list(x = list(type = "number")), mode = "tempdir",
  executor = function(fn, args, workdir, timeout_s)
    list(stdout = "", result = do.call(fn, args),
         files = character(0), status = "ok", error = NA)
)
double$fn(x = 21)
} # }