diff --git a/R/commons.R b/R/commons.R index b3e06fb..ba024e1 100644 --- a/R/commons.R +++ b/R/commons.R @@ -132,7 +132,8 @@ commons <- function( semantic_layer <- semantic_layer %||% new_semantic_layer() check_semantic_layer(semantic_layer) network <- rlang::arg_match(network) - check_run_r_sandbox() + sandbox_mode <- run_r_sandbox_mode() + check_run_r_sandbox(sandbox_mode = sandbox_mode) check_instructions(instructions) rlang::check_bool(log) check_share_with(share_with) @@ -143,6 +144,7 @@ commons <- function( context_layer = context_layer, semantic_layer = semantic_layer, network = network, + sandbox_mode = sandbox_mode, instructions = instructions, log = log, share_with = share_with @@ -161,6 +163,7 @@ Commons <- R6::R6Class( ..., instructions = NULL, network = c("none", "full"), + sandbox_mode = run_r_sandbox_mode(), log = FALSE, share_with = NULL ) { @@ -168,6 +171,10 @@ Commons <- R6::R6Class( do.call(super$initialize, ellmer_chat_initialize_args(client)) semantic_layer <- semantic_layer %||% new_semantic_layer() network <- rlang::arg_match(network) + sandbox_mode <- rlang::arg_match( + sandbox_mode, + c("auto", "landlock", "userns", "nsjail") + ) sources <- as_data_sources(data_sources) @@ -198,7 +205,7 @@ Commons <- R6::R6Class( ) private$handles <- new_handle_store() - private$worker <- new_r_worker(network) + private$worker <- new_r_worker(network, sandbox_mode) private$corpus <- build_citation_corpus( private$context_layer, private$registry, diff --git a/R/run-r.R b/R/run-r.R index d2f1899..d6a3ef7 100644 --- a/R/run-r.R +++ b/R/run-r.R @@ -218,9 +218,17 @@ run_r_html <- function(code, segments) { # --- worker lifecycle -------------------------------------------------------- -new_r_worker <- function(network = "none") { +new_r_worker <- function( + network = "none", + sandbox_mode = run_r_sandbox_mode() +) { + sandbox_mode <- rlang::arg_match( + sandbox_mode, + c("auto", "landlock", "userns", "nsjail") + ) worker <- new.env(parent = emptyenv()) worker$network <- network + worker$sandbox_mode <- sandbox_mode worker$rs <- NULL worker$synced <- 0L worker$tail <- NULL @@ -242,10 +250,22 @@ worker_ensure <- function(worker, fn_sources = character()) { } work_dir <- tempfile("commons-worker-") dir.create(work_dir, recursive = TRUE) + parent_tmp <- tempdir() + options <- worker_session_options( + work_dir, + parent_tmp, + worker$network, + worker$sandbox_mode + ) + if (identical(worker$sandbox_mode, "nsjail")) { + nsjail_log( + "starting callr worker: network=%s work_dir=%s", + worker$network, + work_dir + ) + } rs <- callr::r_session$new( - callr::r_session_options( - env = worker_scrubbed_env(work_dir, worker_single_thread("auto")) - ), + options, wait = TRUE ) # callr rebinds a transferred function's environment to the worker's global @@ -256,10 +276,12 @@ worker_ensure <- function(worker, fn_sources = character()) { rs$run( worker_init, args = list( - parent_tmp = tempdir(), + parent_tmp = parent_tmp, work_dir = work_dir, dll_path = commons_dll_path(), - network = worker$network + network = worker$network, + sandbox_mode = worker$sandbox_mode, + sandboxed_by_nsjail = identical(worker$sandbox_mode, "nsjail") ) ) # Defining sources at spawn (rather than syncing per call like handles) @@ -267,6 +289,9 @@ worker_ensure <- function(worker, fn_sources = character()) { if (length(fn_sources)) { rs$run(worker_define_functions, args = list(fn_sources = fn_sources)) } + if (identical(worker$sandbox_mode, "nsjail")) { + nsjail_log("callr worker initialized") + } worker$rs <- rs worker$synced <- 0L invisible(worker) @@ -285,6 +310,33 @@ worker_single_thread <- function(sandbox_mode) { ) } +nsjail_path <- function() { + path <- getOption("commons.nsjail_path", Sys.which("nsjail")) + if (!is.character(path) || length(path) != 1 || is.na(path)) { + return("") + } + path +} + +worker_session_options <- function( + work_dir, + parent_tmp, + network, + sandbox_mode +) { + env <- worker_scrubbed_env( + work_dir, + worker_single_thread(sandbox_mode) + ) + options <- callr::r_session_options(env = env) + if (!identical(sandbox_mode, "nsjail")) { + return(options) + } + + spec <- nsjail_worker_spec(work_dir, parent_tmp, network, env) + callr::r_session_options(env = env, arch = spec$wrapper) +} + commons_dll_path <- function() { dll <- getLoadedDLLs()[["commons"]] if (is.null(dll)) { @@ -332,6 +384,200 @@ worker_scrubbed_env <- function(work_dir, single_thread = FALSE) { env } +nsjail_quote <- function(x) { + if (!is.character(x) || length(x) != 1 || is.na(x) || + grepl("[[:cntrl:]]", x)) { + cli::cli_abort("nsjail configuration values must be one-line strings.") + } + x <- gsub("\\", "\\\\", x, fixed = TRUE) + x <- gsub("\"", "\\\"", x, fixed = TRUE) + paste0("\"", x, "\"") +} + +worker_sandbox_roots <- function(parent_tmp, work_dir, sysname) { + resolve <- function(paths) { + vapply( + paths, + function(p) tryCatch(normalizePath(p), error = function(e) p), + character(1), + USE.NAMES = FALSE + ) + } + pkg_dirs <- list.dirs(.libPaths(), recursive = FALSE) + os_roots <- switch( + sysname, + Linux = c("/usr", "/bin", "/sbin", "/lib", "/lib64", "/etc", "/opt/R"), + Darwin = c( + "/usr", "/bin", "/sbin", "/System", "/Library", + "/private/etc", "/private/var/db", "/opt", "/dev" + ) + ) + read_roots <- unique(c( + R.home(), + .libPaths(), + resolve(pkg_dirs), + os_roots + )) + read_roots <- read_roots[dir.exists(read_roots)] + read_roots <- unique(c(read_roots, resolve(read_roots))) + write_roots <- c(parent_tmp, work_dir) + write_roots <- unique(c(write_roots, resolve(write_roots))) + list(read = read_roots, write = write_roots) +} + +nsjail_seccomp <- function(network) { + namespace_flags <- paste( + c( + "0x10000000", "0x00020000", "0x40000000", "0x20000000", + "0x08000000", "0x04000000", "0x02000000" + ), + collapse = " | " + ) + denied <- c( + "ptrace", + "process_vm_readv", + "process_vm_writev", + "mount", + "pivot_root", + "chroot", + "unshare", + "setns", + "open_tree", + "move_mount", + "fsopen", + "fsconfig", + "fsmount", + "fspick", + "mount_setattr" + ) + if (identical(network, "none")) { + denied <- c(denied, "socket", "io_uring_setup") + } + unmount <- c( + "SYSCALL[166] ON x86_64", + "SYSCALL[39] ON aarch64", + "SYSCALL[52] ON { x86, arm }" + ) + c( + "POLICY commons {", + paste0( + " ERRNO(1) { ", + paste(c(denied, unmount), collapse = ", "), + "," + ), + paste0(" clone { (clone_flags & (", namespace_flags, ")) != 0 }"), + " },", + " ERRNO(38) { clone3 }", + "}", + "USE commons DEFAULT ALLOW" + ) +} + +nsjail_worker_spec <- function(work_dir, parent_tmp, network, env) { + network <- rlang::arg_match(network, c("none", "full")) + if (!identical(Sys.info()[["sysname"]], "Linux")) { + cli::cli_abort("{.code nsjail} is only supported on Linux.") + } + nsjail <- nsjail_path() + if (!nzchar(nsjail) || file.access(nsjail, mode = 1) != 0) { + cli::cli_abort( + "commons cannot sandbox the {.code run_r} session because {.code nsjail} + is not installed or is not executable." + ) + } + + roots <- worker_sandbox_roots(parent_tmp, work_dir, "Linux") + nsjail_log( + "building policy: binary=%s network=%s read_roots=%d write_roots=%d", + nsjail, + network, + length(roots$read), + length(roots$write) + ) + mount <- function(path, rw) { + c( + "mount {", + paste0(" src: ", nsjail_quote(path)), + paste0(" dst: ", nsjail_quote(path)), + " is_bind: true", + paste0(" rw: ", tolower(as.character(rw))), + " mandatory: true", + "}" + ) + } + env_values <- env[!is.na(env)] + inherited <- Sys.getenv( + c( + "PATH", "LANG", "LD_LIBRARY_PATH", "R_HOME", "R_LIBS", "R_LIBS_USER", + "R_LIBS_SITE", "R_ENVIRON", "R_ENVIRON_USER", "R_PROFILE", + "R_PROFILE_USER", names(Sys.getenv())[startsWith(names(Sys.getenv()), "LC_")] + ), + unset = NA_character_ + ) + inherited <- inherited[!is.na(inherited)] + child_env <- c(inherited, env_values) + child_env <- child_env[!duplicated(names(child_env), fromLast = TRUE)] + config <- c( + "name: \"commons-run-r\"", + "mode: EXECVE", + paste0("cwd: ", nsjail_quote(work_dir)), + "time_limit: 0", + "disable_rl: true", + "keep_env: false", + "clone_newuser: true", + "clone_newns: true", + paste0("clone_newnet: ", tolower(as.character(identical(network, "none")))), + "clone_newpid: false", + "clone_newipc: false", + "clone_newuts: false", + "clone_newcgroup: false", + "uidmap { inside_id: \"0\" outside_id: \"\" count: 1 }", + "gidmap { inside_id: \"0\" outside_id: \"\" count: 1 }", + # callr uses fd 3 for its control protocol and redirects output to fds 5 + # and 6 while a request is running. nsjail otherwise retains only stdio. + "pass_fd: 3", + "pass_fd: 5", + "pass_fd: 6", + unlist(lapply(roots$read, mount, rw = FALSE), use.names = FALSE), + unlist(lapply(roots$write, mount, rw = TRUE), use.names = FALSE), + paste0( + "envar: ", + vapply( + paste0(names(child_env), "=", unname(child_env)), + nsjail_quote, + character(1) + ) + ), + paste0("seccomp_string: ", vapply(nsjail_seccomp(network), nsjail_quote, character(1))) + ) + config_path <- file.path(work_dir, ".commons-nsjail.cfg") + wrapper_path <- file.path(work_dir, ".commons-nsjail") + writeLines(config, config_path, useBytes = TRUE) + writeLines( + c( + "#!/bin/sh", + paste( + "exec", + shQuote(nsjail), + "--config", + shQuote(config_path), + "--", + shQuote(normalizePath(file.path(R.home("bin"), "R"))), + "\"$@\"" + ) + ), + wrapper_path, + useBytes = TRUE + ) + Sys.chmod(wrapper_path, mode = "0755") + nsjail_log( + "launch configuration written: config=%s wrapper=%s", + config_path, + wrapper_path + ) + list(config = config_path, wrapper = wrapper_path) +} + # Close the worker after a quiet stretch; it respawns lazily on the next # call. Stray timers are harmless: they check recency before acting. schedule_worker_reap <- function( @@ -461,7 +707,8 @@ worker_init <- function( work_dir, dll_path, network = "none", - sandbox_mode = "auto" + sandbox_mode = "auto", + sandboxed_by_nsjail = FALSE ) { setwd(work_dir) options(width = 80, cli.num_colors = 1) @@ -474,67 +721,69 @@ worker_init <- function( "; only Linux and macOS are supported." ) } - if (is.na(dll_path)) { + if (is.na(dll_path) && !sandboxed_by_nsjail) { stop( "commons cannot sandbox the run_r session: its compiled library was ", "not found. Install commons as a package, or bundle its src/ directory ", "so load_all() can compile it." ) } - if (!("commons" %in% names(getLoadedDLLs()))) { + if (!sandboxed_by_nsjail && !("commons" %in% names(getLoadedDLLs()))) { dyn.load(dll_path) } - resolve <- function(paths) { - vapply( - paths, - function(p) tryCatch(normalizePath(p), error = function(e) p), - character(1), - USE.NAMES = FALSE + if (!sandboxed_by_nsjail) { + resolve <- function(paths) { + vapply( + paths, + function(p) tryCatch(normalizePath(p), error = function(e) p), + character(1), + USE.NAMES = FALSE + ) + } + # The sandboxes match symlink-free paths: Connect's packrat library is a + # farm of symlinks into a shared cache, and macOS's /tmp and /var live + # under /private, so grant resolved paths alongside the originals. + pkg_dirs <- list.dirs(.libPaths(), recursive = FALSE) + os_roots <- switch( + sysname, + Linux = c( + "/usr", "/bin", "/sbin", "/lib", "/lib64", "/etc", "/opt/R" + ), + Darwin = c( + "/usr", "/bin", "/sbin", "/System", "/Library", + "/private/etc", "/private/var/db", "/opt", "/dev" + ) ) - } - # The sandboxes match symlink-free paths: Connect's packrat library is a - # farm of symlinks into a shared cache, and macOS's /tmp and /var live - # under /private, so grant resolved paths alongside the originals. - pkg_dirs <- list.dirs(.libPaths(), recursive = FALSE) - os_roots <- switch( - sysname, - Linux = c( - "/usr", "/bin", "/sbin", "/lib", "/lib64", "/etc", "/opt/R" - ), - Darwin = c( - "/usr", "/bin", "/sbin", "/System", "/Library", - "/private/etc", "/private/var/db", "/opt", "/dev" + read_roots <- unique(c( + R.home(), + .libPaths(), + resolve(pkg_dirs), + os_roots + )) + read_roots <- read_roots[dir.exists(read_roots)] + read_roots <- unique(c(read_roots, resolve(read_roots))) + # callr writes its per-call result files into the parent's tempdir, so the + # worker must be able to write there for results to make it back. + write_roots <- c(parent_tmp, work_dir) + write_roots <- unique(c(write_roots, resolve(write_roots))) + # callr reports status on fd 3 and saves stdout/stderr while a call runs. + callr_data <- as.environment("tools:callr")[["__callr_data__"]] + preserve_fds <- c( + 3L, + callr_data[[".__stdout__"]], + callr_data[[".__stderr__"]] ) - ) - read_roots <- unique(c( - R.home(), - .libPaths(), - resolve(pkg_dirs), - os_roots - )) - read_roots <- read_roots[dir.exists(read_roots)] - read_roots <- unique(c(read_roots, resolve(read_roots))) - # callr writes its per-call result files into the parent's tempdir, so the - # worker must be able to write there for results to make it back. - write_roots <- c(parent_tmp, work_dir) - write_roots <- unique(c(write_roots, resolve(write_roots))) - # callr reports status on fd 3 and saves stdout/stderr while a call runs. - callr_data <- as.environment("tools:callr")[["__callr_data__"]] - preserve_fds <- c( - 3L, - callr_data[[".__stdout__"]], - callr_data[[".__stderr__"]] - ) - sym <- getNativeSymbolInfo("c_sandbox_engage", PACKAGE = "commons") - .Call( - sym, - read_roots, - write_roots, - NULL, - sandbox_mode, - preserve_fds, - network - ) + sym <- getNativeSymbolInfo("c_sandbox_engage", PACKAGE = "commons") + .Call( + sym, + read_roots, + write_roots, + NULL, + sandbox_mode, + preserve_fds, + network + ) + } # Register methods for integer64 columns transferred from ODBC results. requireNamespace("bit64", quietly = TRUE) invisible(TRUE) diff --git a/R/sandbox.R b/R/sandbox.R index 6526a0f..90709ca 100644 --- a/R/sandbox.R +++ b/R/sandbox.R @@ -2,21 +2,57 @@ # sandbox itself in worker_init() (run-r.R), calling the C symbol directly; # the parent process is never sandboxed. +nsjail_log <- function(format, ...) { + cat( + "[commons][nsjail] ", + sprintf(format, ...), + "\n", + sep = "", + file = stdout() + ) +} + sandbox_capabilities <- function() { caps <- .Call(c_sandbox_capabilities) + nsjail <- nsjail_path() list( landlock_abi = caps[[1]], seccomp = caps[[2]] > 0, seatbelt = caps[[3]] > 0, - userns = caps[[4]] > 0 + userns = caps[[4]] > 0, + nsjail = nzchar(nsjail) && file.access(nsjail, mode = 1) == 0 + ) +} + +run_r_sandbox_mode <- function() { + match.arg( + getOption("commons.run_r_sandbox", "auto"), + c("auto", "landlock", "userns", "nsjail") ) } check_run_r_sandbox <- function( capabilities = sandbox_capabilities(), sysname = Sys.info()[["sysname"]], + sandbox_mode = run_r_sandbox_mode(), call = rlang::caller_env() ) { + if (identical(sandbox_mode, "nsjail")) { + path <- nsjail_path() + nsjail_log( + "requested: binary=%s executable=%s seccomp=%s userns=%s", + if (nzchar(path)) path else "", + nzchar(path) && file.access(path, mode = 1) == 0, + capabilities$seccomp, + capabilities$userns + ) + } + if (identical(sandbox_mode, "nsjail") && !identical(sysname, "Linux")) { + cli::cli_abort( + "{.code nsjail} is only supported on Linux.", + call = call + ) + } if (!identical(sysname, "Linux")) { return(invisible()) } @@ -28,6 +64,23 @@ check_run_r_sandbox <- function( call = call ) } + if (identical(sandbox_mode, "nsjail")) { + if (!capabilities$nsjail) { + cli::cli_abort( + "commons cannot sandbox the {.code run_r} session because {.code nsjail} + is not installed or is not executable.", + call = call + ) + } + if (!capabilities$userns) { + cli::cli_abort( + "commons cannot sandbox the {.code run_r} session with {.code nsjail} + because this host does not allow unprivileged user namespaces.", + call = call + ) + } + return(invisible()) + } if (capabilities$landlock_abi >= 1 || capabilities$userns) { return(invisible()) } diff --git a/SANDBOXING.md b/SANDBOXING.md new file mode 100644 index 0000000..3df0957 --- /dev/null +++ b/SANDBOXING.md @@ -0,0 +1,353 @@ +# Sandboxing `run_r` + +`commons` runs model-authored R code through the `run_r` tool. That code does +not execute in the R process that created the agent. It executes in a separate +`callr::r_session` worker, which must engage an operating-system sandbox before +it evaluates code. + +This document describes the controls implemented in this repository. It is a +description of the implementation, not a claim that the worker is safe to run +against arbitrary host or kernel vulnerabilities. + +## Scope and guarantees + +The sandbox applies to the `run_r` worker only. The parent R process, its +database connections, and the agent process are not sandboxed. The worker is +created lazily and reused for calls to the same agent, so variables and loaded +packages persist until the worker is closed or replaced. + +The worker runs only on Linux and macOS. `commons()` refuses to create an agent +on a Linux host without seccomp and without either Landlock or an unprivileged +user-namespace sandbox. Other operating systems are rejected when the worker +initializes. + +The controls are intended to: + +- prevent the worker from reading or writing files outside a narrow allowlist; +- deny network access by default; +- prevent common in-process sandbox-escape setup operations; +- avoid passing ambient credentials and function environments to the worker; +- limit the lifetime of stuck worker code. + +They do not prevent the worker from computing arbitrarily, loading code from +its readable library paths, or starting ordinary subprocesses. On Linux, the +seccomp policy deliberately permits ordinary `exec` and non-namespace +`clone`; those processes remain subject to the worker's sandbox restrictions. + +## Worker startup controls + +### Separate process + +`run_r` uses a `callr::r_session` rather than evaluating model code in the +agent's R process. The worker's entry points are transferred into the child's +global environment and do not reference the parent process's `commons` +internals. + +Source: [`R/run-r.R`](R/run-r.R#L239-L272) + +### Environment allowlist + +The child is launched with nearly all inherited environment variables removed. +Only execution and locale variables required to start R are retained: + +- `PATH`, `LANG`, and `LC_*`; +- dynamic-library and R installation/library variables; +- R environment and profile variables. + +The worker receives a fresh working directory as both `HOME` and `TMPDIR`. +This specifically prevents ambient API keys, session tokens, database URLs, +and similar environment-based credentials from crossing into the worker. + +Source: [`R/run-r.R`](R/run-r.R#L305-L333) + +### Explicit input boundary + +Results from the agent's trusted tools are copied into the worker as handles. +Measure and helper functions are supplied as source text only. Their original +environments, including connections and credentials captured by those +environments, are not supplied. + +Source: [`R/run-r.R`](R/run-r.R#L543-L554) and +[`R/measures.R`](R/measures.R#L30-L34) + +### Controlled working directory and filesystem roots + +The worker starts in its own temporary directory. Before the OS sandbox is +engaged, `commons` computes these filesystem roots: + +- Read roots: R's home, installed R libraries, resolved library directories, + and a small OS-specific set of system directories required to run R. +- Write roots: the worker directory and the parent `callr` temporary directory + used to return results. + +Both literal and resolved paths are included so that symlinked R libraries and +macOS's `/tmp` and `/var` aliases work correctly. + +This is an allowlist, not a claim that the permitted system directories contain +only safe content. In particular, a worker can read the system and R package +files needed to execute. + +Source: [`R/run-r.R`](R/run-r.R#L459-L537) + +## Linux controls + +Linux applies one filesystem backend, then always applies an escape-prevention +seccomp policy. The default backend selection is Landlock first and user/mount +namespaces as a fallback. An optional nsjail backend is selected with: + +```r +options(commons.run_r_sandbox = "nsjail") +``` + +This mode requires an executable `nsjail` on `PATH` (or at +`options(commons.nsjail_path = ...)`) and a host that permits unprivileged user +namespaces. It is a launch-time backend: nsjail starts the `callr` worker inside +its namespaces before R begins evaluating worker initialization. + +### Linux control map + +This table maps each Linux isolation control to the kernel interface that +facilitates it. A control can use more than one interface. Entries marked "Not +directly invoked by `commons`" are implemented by R or `callr`, rather than the +native sandbox layer. + +| Isolation | Mechanism | Linux system call or kernel interface | +| --- | --- | --- | +| Separate execution process | `callr::r_session` starts the worker outside the agent's R process. | Not directly invoked by `commons`; `callr`/`processx` creates the child process. | +| Ambient credential isolation | The worker starts with an environment allowlist and a fresh `HOME` and `TMPDIR`. | No kernel isolation syscall; environment construction occurs before process launch. | +| Filesystem allowlist | Landlock grants read-only or read-write access only below declared roots. | `landlock_create_ruleset`, `landlock_add_rule`, `landlock_restrict_self` | +| Landlock ABI coverage | The ruleset handles all filesystem rights known to the detected Landlock ABI. | `landlock_create_ruleset` with `LANDLOCK_CREATE_RULESET_VERSION` | +| Filesystem fallback | A user namespace and mount namespace isolate the worker's mount view. | `unshare(CLONE_NEWUSER | CLONE_NEWNS)` | +| nsjail filesystem backend | nsjail starts the worker in a user/mount namespace and `pivot_root`s into a mount tree containing only declared bind mounts. | nsjail invokes `unshare`, `mount`, `pivot_root`, and `umount2` before it `execve`s R | +| nsjail control-descriptor preservation | The generated policy preserves callr's protocol and redirected-output descriptors while nsjail closes other inherited descriptors before R executes. | `fcntl(FD_CLOEXEC)` through nsjail; policy `pass_fd: 3`, `pass_fd: 5`, and `pass_fd: 6` | +| User and group mapping | The fallback maps the caller's UID/GID and disables supplementary-group changes. | `open`, `write`, and `close` on `/proc/self/setgroups`, `/proc/self/uid_map`, and `/proc/self/gid_map` | +| Host mount protection | The fallback makes mount propagation private before changing mounts. | `mount(..., MS_PRIVATE | MS_REC, ...)` | +| New filesystem root | The fallback mounts a tmpfs root, pivots into it, then detaches the host root. | `mount("tmpfs", ...)`, `pivot_root`, `umount2` | +| Read-only filesystem roots | The fallback recursively bind-mounts declared roots and remounts read roots read-only. | `mount(..., MS_BIND | MS_REC, ...)`, `mount(..., MS_REMOUNT | MS_BIND | MS_RDONLY, ...)` | +| Inherited-descriptor isolation | The fallback closes inherited sockets and file/device descriptors outside allowed roots, preserving only callr's required descriptors. | `open`/`readdir` on `/proc/self/fd`, `fstat`, `readlink`, `fcntl`, `close` | +| Capability isolation | The fallback removes all capabilities from both the bounding set and current capability sets. | `prctl(PR_CAPBSET_DROP, ...)`, `capset` | +| No privilege gain on exec | The worker forbids gaining privileges after sandbox setup. | `prctl(PR_SET_NO_NEW_PRIVS, 1, ...)` | +| Sandbox-escape syscall denial | Seccomp denies cross-process memory access, mount/chroot operations, namespace operations, and namespace-creating clones. | `prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, ...)` with a BPF filter | +| Network denial by default | A second seccomp filter denies `socket` and `io_uring_setup`. | `prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, ...)` with a BPF filter | +| Worker address-space limit | The native API can limit virtual address space, but the normal `run_r` path passes no limit. | `setrlimit(RLIMIT_AS, ...)` when configured; not currently enabled by `worker_init()` | +| Time limit and worker replacement | The parent interrupts and, if needed, closes an unresponsive worker. | Not a sandbox syscall; implemented through `callr`/`processx` process control. | + +### `no_new_privs` + +Before applying Landlock or seccomp, the worker sets `PR_SET_NO_NEW_PRIVS`. +This prevents later execution from gaining privileges through set-user-ID, +set-group-ID, or file-capability mechanisms. + +Source: [`src/sandbox.c`](src/sandbox.c#L677-L680) + +### Landlock filesystem confinement + +Landlock is the preferred filesystem backend. `commons` invokes the Landlock +syscalls directly because the implementation does not depend on a libc wrapper. +It follows the Landlock userspace API documented in the +[Linux kernel documentation](https://docs.kernel.org/userspace-api/landlock.html): + +1. Query the supported Landlock ABI using `LANDLOCK_CREATE_RULESET_VERSION`. +2. Create a ruleset that handles every supported filesystem access right. +3. Add a `LANDLOCK_RULE_PATH_BENEATH` rule for every read and read-write root. +4. Apply the ruleset permanently to the worker with + `landlock_restrict_self`. + +Read roots receive execute, file-read, and directory-read access. Write roots +receive every filesystem right handled by the detected ABI. Access to handled +rights outside those roots is denied. + +Landlock leaves rights that are not declared in the ruleset unrestricted. +Accordingly, `commons` declares the base ABI 1 filesystem rights and adds: + +- `REFER` on ABI 2 and later; +- `TRUNCATE` on ABI 3 and later; +- device `IOCTL` on ABI 5 and later. + +Source: [`src/sandbox.c`](src/sandbox.c#L100-L178) + +### User and mount namespace fallback + +If Landlock is unavailable because the kernel does not implement or permit it, +the worker attempts a namespace-based filesystem sandbox. This backend: + +1. Requires a single-threaded process, then creates a user namespace and a + mount namespace. +2. Maps only the calling UID and GID, after writing `deny` to `setgroups`. +3. Makes mount propagation private, preventing its mount changes from + propagating to the host mount namespace. +4. Closes inherited sockets and closes file/device descriptors that do not + point to declared read or write roots. The descriptors necessary for `callr` + status and output are preserved. +5. Mounts a 16 MiB tmpfs as a new root and uses `pivot_root`. +6. Recursively bind-mounts only the declared roots into that new root. +7. Remounts all read roots read-only, including nested mounts. +8. Detaches the original host root and remounts the sandbox root read-only. +9. Drops every capability from both the capability bounding set and the current + capability sets. + +The namespace fallback does not create a PID namespace or a network namespace. +Network isolation comes from seccomp, and ordinary subprocesses are permitted. + +Source: [`src/sandbox.c`](src/sandbox.c#L216-L540) + +### nsjail backend + +The optional `nsjail` backend applies the same path allowlist at process +creation rather than by calling the package's native sandbox API from +`worker_init()`. `commons` generates a per-worker nsjail Protobuf configuration +and a Kafel seccomp policy, then uses a short executable wrapper as the +`callr` worker binary. + +The generated configuration: + +1. creates user and mount namespaces, plus a network namespace for + `network = "none"`; +2. maps the current user and group into the user namespace; +3. lets nsjail build a new mount tree and `pivot_root` into it, with only the + same read-only and read/write roots used by the native namespace backend; +4. clears the inherited environment and supplies the existing execution and + locale allowlist; +5. preserves only standard I/O and callr's required control/output + descriptors (`3`, `5`, and `6`); +6. keeps nsjail's default `no_new_privs` and capability-drop behavior; +7. installs a generated Kafel policy that denies the same escape setup + operations as the native filter, including namespace-creating `clone` + calls, and additionally denies `socket` and `io_uring_setup` in offline + mode. + +The parent still builds the environment and transfers trusted inputs as +described above. Those are application-level controls, not nsjail policy +features. + +Source: [`R/run-r.R`](R/run-r.R#L303-L554) + +### Seccomp escape-prevention policy + +After the filesystem backend has engaged, the worker installs a seccomp BPF +filter. It verifies that syscalls use the expected native architecture and, on +x86_64, rejects the x32 syscall ABI. The filter returns `EPERM` for: + +- `ptrace`; +- `process_vm_readv` and `process_vm_writev`; +- mount, unmount, `pivot_root`, and `chroot`; +- `unshare` and `setns`; +- the newer mount APIs (`open_tree`, `move_mount`, `fsopen`, `fsconfig`, + `fsmount`, `fspick`, and `mount_setattr`); +- `clone3`; +- `clone` requests carrying any namespace-creation flag. + +The `clone3` denial causes glibc to use `clone`, whose flags the BPF program +can inspect. Ordinary threads and subprocesses that do not create a namespace +remain allowed. + +Source: [`src/sandbox.c`](src/sandbox.c#L543-L604) + +### Network policy + +`network = "none"` is the default. In that mode, `commons` installs a second +seccomp filter that returns `EPERM` for `socket` and `io_uring_setup`, blocking +ordinary socket creation and an alternate asynchronous-I/O path. The worker +can be configured with `network = "full"`; that omits this network filter but +does not relax filesystem confinement or the escape-prevention seccomp filter. + +Source: [`src/sandbox.c`](src/sandbox.c#L606-L630) and +[`R/commons.R`](R/commons.R#L104-L145) + +## macOS controls + +On macOS, `commons` uses the Seatbelt `sandbox_init()` API from `libSystem`. +It dynamically builds an SBPL profile with these rules: + +- start from `(allow default)`; +- deny `network*` unless `network = "full"`; +- deny `file-write*`, then allow writes to `/dev/null` and the explicit + read-write roots; +- deny `file-read*`, then allow reads from the explicit read-only and + read-write roots; +- allow `file-read-metadata` globally so that the worker can traverse paths. + +The last rule means the worker can learn file metadata or existence outside its +read roots, but cannot read file contents through the policy. Root paths +containing a quote or backslash are rejected before they are interpolated into +the SBPL profile. + +The macOS backend does not apply Linux's Landlock, namespace, seccomp, or +inherited-file-descriptor controls. + +Source: [`src/sandbox.c`](src/sandbox.c#L717-L828) + +## Execution and lifecycle safeguards + +These mechanisms complement the OS sandbox but are not filesystem or privilege +isolation controls. + +### Time limit and worker replacement + +Each `run_r` call has a configurable 60-second default wall-clock timeout. The +parent first interrupts the worker. If it remains unresponsive for another five +seconds, the parent closes it and the next call starts a fresh worker. + +Source: [`R/run-r.R`](R/run-r.R#L353-L436) + +### Worker cleanup + +The worker is closed when its agent is garbage-collected and after a +configurable idle period (default 600 seconds). This limits stray worker +processes and clears persisted worker state after inactivity. + +Source: [`R/run-r.R`](R/run-r.R#L221-L232) and +[`R/run-r.R`](R/run-r.R#L335-L350) + +### Memory-limit support is not currently enabled + +The native API accepts an optional address-space limit and uses `RLIMIT_AS` on +Linux when a limit is supplied. `worker_init()` currently passes `NULL`, so +normal `run_r` execution does not receive a memory limit. macOS accepts the +same argument for interface parity, but the implementation notes that the +kernel does not enforce it reliably. + +Source: [`src/sandbox.c`](src/sandbox.c#L669-L675), +[`src/sandbox.c`](src/sandbox.c#L774-L782), and +[`R/run-r.R`](R/run-r.R#L528-L537) + +## Verification + +The sandbox tests start a real worker, create a canary file outside its allowed +roots, and assert that the worker cannot read or write it. They also assert +that socket creation and an attempted `curl` subprocess are denied by default. +The user-namespace test separately verifies that an inherited file descriptor +to the canary cannot be used. The nsjail test exercises the same read, write, +socket, subprocess, and ordinary-exec probes when nsjail and user namespaces +are available. A full-network test verifies that enabling network access does +not relax filesystem confinement. + +Source: [`tests/testthat/test-sandbox.R`](tests/testthat/test-sandbox.R#L73-L232) + +[`tools/nsjail.Dockerfile`](tools/nsjail.Dockerfile) builds a Linux smoke-test +image from nsjail's source and runs the nsjail worker chain against an external +canary. Docker's default seccomp profile blocks the nested +`unshare(CLONE_NEWUSER | CLONE_NEWNS | CLONE_NEWNET)` call required by nsjail, +so this smoke test must run on a Linux container runtime configured to permit +unprivileged user and mount namespaces. It must not be treated as a reason to +weaken the production container profile broadly. + +## Limitations and non-goals + +- The sandbox does not protect against kernel, R, package, or permitted-system + binary vulnerabilities. +- The worker can read every file below its configured read roots, which include + R and system directories needed for execution. +- The worker can execute programs available in those roots. The sandbox relies + on the filesystem and syscall restrictions to constrain those programs. +- `network = "full"` intentionally permits network access. +- The namespace fallback's explicit inherited-FD pruning is not performed by + the Landlock or Seatbelt backends. +- The nsjail backend requires an executable nsjail and a runtime that permits + creating user and mount namespaces. Standard Docker profiles commonly block + this nested namespace creation. +- Seatbelt intentionally allows global file-read metadata, which leaks file + existence and some metadata outside the allowlist. +- The worker has no active memory limit in the current `run_r` call path. +- A sandboxed worker can still consume CPU until the parent timeout interrupts + or replaces it. diff --git a/inst/app.R b/inst/app.R index c8ea525..6139af4 100644 --- a/inst/app.R +++ b/inst/app.R @@ -3,6 +3,51 @@ library(commons) library(shiny) library(shinychat) +options(commons.run_r_sandbox = "nsjail") + +package_revision <- function(pkg) { + desc <- packageDescription(pkg) + sha <- desc[["RemoteSha"]] + if (is.null(sha)) { + sha <- desc[["GithubSHA1"]] + } + if (is.null(sha) || is.na(sha) || !nzchar(sha)) { + return("") + } + sha +} + +nsjail <- Sys.which("nsjail") +cat( + sprintf( + paste0( + "[commons][nsjail] app startup: mode=%s commons_path=%s ", + "binary=%s executable=%s\n" + ), + getOption("commons.run_r_sandbox"), + getNamespaceInfo(asNamespace("commons"), "path"), + if (nzchar(nsjail)) nsjail else "", + nzchar(nsjail) && file.access(nsjail, mode = 1) == 0 + ), + file = stdout() +) + +cat( + sprintf( + paste0( + "[commons][history] ellmer=%s sha=%s path=%s ", + "shinychat=%s sha=%s path=%s\n" + ), + as.character(packageVersion("ellmer")), + package_revision("ellmer"), + system.file(package = "ellmer"), + as.character(packageVersion("shinychat")), + package_revision("shinychat"), + system.file(package = "shinychat") + ), + file = stdout() +) + observations <- data.frame( site = c( "Cedar Fen", "Cedar Fen", "Cedar Fen", "Cedar Fen", "Cedar Fen", "Cedar Fen", diff --git a/inst/manifest.json b/inst/manifest.json index 386872e..181b9dc 100644 --- a/inst/manifest.json +++ b/inst/manifest.json @@ -474,11 +474,11 @@ "RemoteRepo": "commons", "RemoteUsername": "posit-dev", "RemoteRef": "HEAD", - "RemoteSha": "c394313a30e800c4d71dbf8ab62791448ec0102f", + "RemoteSha": "9fbd7a3977a117c195843bcac2848f771700a679", "GithubRepo": "commons", "GithubUsername": "posit-dev", "GithubRef": "HEAD", - "GithubSHA1": "c394313a30e800c4d71dbf8ab62791448ec0102f", + "GithubSHA1": "9fbd7a3977a117c195843bcac2848f771700a679", "NeedsCompilation": "yes", "Packaged": "2026-08-04 17:29:25 UTC; taylor", "Author": "Simon Couch [aut, cre] (ORCID: ),\n Posit Software, PBC [cph, fnd] (ROR: )", @@ -732,12 +732,12 @@ } }, "ellmer": { - "Source": "CRAN", - "Repository": "https://cloud.r-project.org", + "Source": "github", + "Repository": null, "description": { "Package": "ellmer", "Title": "Chat with Large Language Models", - "Version": "0.4.2", + "Version": "0.4.2.9000", "Authors@R": "c(\n person(\"Hadley\", \"Wickham\", , \"hadley@posit.co\", role = c(\"aut\", \"cre\"),\n comment = c(ORCID = \"0000-0003-4757-117X\")),\n person(\"Joe\", \"Cheng\", role = \"aut\"),\n person(\"Aaron\", \"Jacobs\", role = \"aut\"),\n person(\"Garrick\", \"Aden-Buie\", , \"garrick@posit.co\", role = \"aut\",\n comment = c(ORCID = \"0000-0002-7111-0077\")),\n person(\"Barret\", \"Schloerke\", , \"barret@posit.co\", role = \"aut\",\n comment = c(ORCID = \"0000-0001-9986-114X\")),\n person(\"Posit Software, PBC\", role = c(\"cph\", \"fnd\"),\n comment = c(ROR = \"03wc8by49\"))\n )", "Description": "Chat with large language models from a range of providers\n including 'Claude' , 'OpenAI'\n , and more. Supports streaming, asynchronous\n calls, tool calling, and structured data extraction.", "License": "MIT + file LICENSE", @@ -749,11 +749,22 @@ "VignetteBuilder": "knitr", "Config/Needs/prices": "cli, dplyr, jsonlite, jsonvalidate, stringr,\ntidyr, usethis", "Config/Needs/website": "tidyverse/tidytemplate, rmarkdown", - "Config/roxygen2/version": "8.0.0", + "Config/roxygen2/version": "8.1.0", "Config/testthat/edition": "3", "Config/testthat/parallel": "true", "Config/testthat/start-first": "chat, provider*", "Encoding": "UTF-8", + "Roxygen": "list(markdown = TRUE)", + "RemoteType": "github", + "RemoteHost": "api.github.com", + "RemoteRepo": "ellmer", + "RemoteUsername": "tidyverse", + "RemoteRef": "HEAD", + "RemoteSha": "dd1c8965c8e35d94a0fcaa6b452234e7e95e432d", + "GithubRepo": "ellmer", + "GithubUsername": "tidyverse", + "GithubRef": "HEAD", + "GithubSHA1": "dd1c8965c8e35d94a0fcaa6b452234e7e95e432d", "Collate": "'utils-S7.R' 'types.R' 'ellmer-package.R' 'tools-def.R'\n'content.R' 'provider.R' 'as-json.R' 'batch-chat.R'\n'chat-structured.R' 'chat-tools-content.R' 'turns.R'\n'chat-tools.R' 'chat-utils.R' 'utils-coro.R' 'chat.R'\n'content-image.R' 'content-pdf.R' 'content-replay.R' 'httr2.R'\n'import-standalone-defer.R' 'import-standalone-obj-type.R'\n'import-standalone-purrr.R' 'import-standalone-types-check.R'\n'interpolate.R' 'live.R' 'otel.R' 'parallel-chat.R' 'params.R'\n'provider-any.R' 'provider-aws.R'\n'provider-openai-compatible.R' 'provider-azure.R'\n'provider-claude-files.R' 'provider-claude-tools.R'\n'provider-claude.R' 'provider-google.R' 'provider-cloudflare.R'\n'provider-databricks.R' 'provider-deepseek.R'\n'provider-github.R' 'provider-google-tools.R'\n'provider-google-upload.R' 'provider-groq.R'\n'provider-huggingface.R' 'provider-lmstudio.R'\n'provider-mistral.R' 'provider-ollama.R'\n'provider-openai-tools.R' 'provider-openai.R'\n'provider-openrouter.R' 'provider-perplexity.R'\n'provider-portkey.R' 'provider-posit.R' 'provider-snowflake.R'\n'provider-vllm.R' 'schema.R' 'stream-controller.R' 'tokens.R'\n'tools-built-in.R' 'tools-def-auto.R' 'utils-auth.R'\n'utils-callbacks.R' 'utils-cat.R' 'utils-merge.R'\n'utils-prettytime.R' 'utils.R' 'zzz.R'", "NeedsCompilation": "no", "Packaged": "2026-07-13 13:22:49 UTC; nic", @@ -2096,12 +2107,12 @@ "RemoteRepo": "shinychat", "RemoteUsername": "posit-dev", "RemoteRef": "HEAD", - "RemoteSha": "d7235ea42b01a50abbd0a9512ab10eea79e8351a", + "RemoteSha": "be8b827c2238055650e63748ccd36860a2b99361", "RemoteSubdir": "pkg-r", "GithubRepo": "shinychat", "GithubUsername": "posit-dev", "GithubRef": "HEAD", - "GithubSHA1": "d7235ea42b01a50abbd0a9512ab10eea79e8351a", + "GithubSHA1": "be8b827c2238055650e63748ccd36860a2b99361", "GithubSubdir": "pkg-r", "NeedsCompilation": "no", "Packaged": "2026-08-04 18:20:35 UTC; taylor", diff --git a/tests/testthat/test-sandbox.R b/tests/testthat/test-sandbox.R index 52485c6..1a61914 100644 --- a/tests/testthat/test-sandbox.R +++ b/tests/testthat/test-sandbox.R @@ -1,10 +1,11 @@ test_that("sandbox_capabilities reports all mechanisms", { caps <- sandbox_capabilities() - expect_named(caps, c("landlock_abi", "seccomp", "seatbelt", "userns")) + expect_named(caps, c("landlock_abi", "seccomp", "seatbelt", "userns", "nsjail")) expect_type(caps$landlock_abi, "integer") expect_type(caps$seccomp, "logical") expect_type(caps$seatbelt, "logical") expect_type(caps$userns, "logical") + expect_type(caps$nsjail, "logical") }) test_that("check_run_r_sandbox rejects unsupported Linux hosts", { @@ -12,7 +13,8 @@ test_that("check_run_r_sandbox rejects unsupported Linux hosts", { landlock_abi = 0L, seccomp = TRUE, seatbelt = FALSE, - userns = FALSE + userns = FALSE, + nsjail = FALSE ) expect_error( @@ -31,7 +33,8 @@ test_that("check_run_r_sandbox accepts either Linux filesystem sandbox", { landlock_abi = 1L, seccomp = TRUE, seatbelt = FALSE, - userns = FALSE + userns = FALSE, + nsjail = FALSE ) expect_invisible(check_run_r_sandbox(capabilities, "Linux")) @@ -40,6 +43,69 @@ test_that("check_run_r_sandbox accepts either Linux filesystem sandbox", { expect_invisible(check_run_r_sandbox(capabilities, "Linux")) }) +test_that("check_run_r_sandbox validates the nsjail backend", { + capabilities <- list( + landlock_abi = 0L, + seccomp = TRUE, + seatbelt = FALSE, + userns = TRUE, + nsjail = FALSE + ) + expect_error( + check_run_r_sandbox(capabilities, "Linux", sandbox_mode = "nsjail"), + "nsjail" + ) + + capabilities$nsjail <- TRUE + expect_output( + expect_invisible( + check_run_r_sandbox(capabilities, "Linux", sandbox_mode = "nsjail") + ), + "\\[commons\\]\\[nsjail\\] requested:" + ) +}) + +test_that("nsjail is rejected outside Linux", { + capabilities <- list( + landlock_abi = 0L, + seccomp = FALSE, + seatbelt = TRUE, + userns = FALSE, + nsjail = TRUE + ) + expect_error( + check_run_r_sandbox(capabilities, "Darwin", sandbox_mode = "nsjail"), + "only supported on Linux" + ) +}) + +test_that("the nsjail policy retains callr descriptors and network controls", { + skip_if_not(identical(Sys.info()[["sysname"]], "Linux")) + work_dir <- tempfile("commons-worker-") + dir.create(work_dir) + withr::defer(unlink(work_dir, recursive = TRUE)) + nsjail <- tempfile("nsjail-") + file.create(nsjail) + Sys.chmod(nsjail, mode = "0755") + withr::defer(unlink(nsjail)) + withr::local_options(commons.nsjail_path = nsjail) + + config <- nsjail_worker_spec( + work_dir = work_dir, + parent_tmp = tempdir(), + network = "none", + env = worker_scrubbed_env(work_dir) + ) + text <- readLines(config$config, warn = FALSE) + policy <- nsjail_seccomp("none") + + expect_true(all(paste0("pass_fd: ", c(3, 5, 6)) %in% text)) + expect_true(any(grepl("^clone_newnet: true$", text))) + expect_match(paste(policy, collapse = "\n"), "socket") + expect_match(paste(policy, collapse = "\n"), "clone_flags") + expect_no_match(paste(nsjail_seccomp("full"), collapse = "\n"), "socket") +}) + test_that("worker_init refuses to run unsandboxed off Linux and macOS", { skip_on_os(c("linux", "mac")) expect_error( @@ -90,30 +156,37 @@ sandboxed_worker_probes <- function( dir.create(work_dir) withr::defer(unlink(work_dir, recursive = TRUE), envir = env) + parent_tmp <- tempdir() rs <- callr::r_session$new( - callr::r_session_options( - env = worker_scrubbed_env(work_dir, worker_single_thread(sandbox_mode)) + worker_session_options( + work_dir, + parent_tmp, + network, + sandbox_mode ) ) withr::defer(rs$close(), envir = env) - rs$run( - function(outside) { - assign( - ".commons_inherited_file", - file(file.path(outside, "secret.txt"), open = "r"), - envir = globalenv() - ) - }, - args = list(outside = outside) - ) + if (!identical(sandbox_mode, "nsjail")) { + rs$run( + function(outside) { + assign( + ".commons_inherited_file", + file(file.path(outside, "secret.txt"), open = "r"), + envir = globalenv() + ) + }, + args = list(outside = outside) + ) + } rs$run( worker_init, args = list( - parent_tmp = tempdir(), + parent_tmp = parent_tmp, work_dir = work_dir, dll_path = commons_dll_path(), network = network, - sandbox_mode = sandbox_mode + sandbox_mode = sandbox_mode, + sandboxed_by_nsjail = identical(sandbox_mode, "nsjail") ) ) @@ -213,6 +286,21 @@ test_that("the user-namespace tier is denied reads, writes, and sockets", { expect_equal(probes$compute, 55) }) +test_that("the nsjail tier is denied reads, writes, and sockets", { + skip_if_not(identical(Sys.info()[["sysname"]], "Linux")) + caps <- sandbox_capabilities() + skip_if_not(caps$nsjail, "nsjail is not installed") + skip_if_not(caps$userns, "no unprivileged user namespaces") + + probes <- sandboxed_worker_probes("nsjail") + expect_equal(probes$read, "denied") + expect_equal(probes$write, "denied") + expect_equal(probes$socket, "denied") + expect_equal(probes$subprocess, "denied") + expect_equal(probes$exec, "allowed") + expect_equal(probes$compute, 55) +}) + test_that("full network access leaves the filesystem sandboxed", { skip_on_os(c("windows", "solaris")) if (identical(Sys.info()[["sysname"]], "Linux")) { diff --git a/tools/nsjail-smoke.R b/tools/nsjail-smoke.R new file mode 100644 index 0000000..a3617ed --- /dev/null +++ b/tools/nsjail-smoke.R @@ -0,0 +1,50 @@ +`%||%` <- function(x, y) { + if (is.null(x)) y else x +} + +source("R/run-r.R") + +outside <- file.path( + dirname(tempdir()), + paste0("commons-nsjail-canary-", Sys.getpid()) +) +dir.create(outside) +on.exit(unlink(outside, recursive = TRUE), add = TRUE) +writeLines("secret", file.path(outside, "secret.txt")) + +worker <- new_r_worker(network = "none", sandbox_mode = "nsjail") +on.exit(worker_close(worker), add = TRUE) +worker_ensure(worker) + +probes <- worker$rs$call( + function(outside) { + denied <- function(expr) { + tryCatch( + { + expr + FALSE + }, + error = function(e) TRUE, + warning = function(w) TRUE + ) + } + list( + read = denied(readLines(file.path(outside, "secret.txt"), n = 1)), + write = denied(file.create(file.path(outside, "written.txt"))), + socket = denied({ + con <- serverSocket(0) + close(con) + }), + compute = sum(1:10) + ) + }, + args = list(outside = outside) +) + +stopifnot( + isTRUE(probes$read), + isTRUE(probes$write), + isTRUE(probes$socket), + identical(probes$compute, 55) +) +message("nsjail smoke test passed") diff --git a/tools/nsjail.Dockerfile b/tools/nsjail.Dockerfile new file mode 100644 index 0000000..73c03d1 --- /dev/null +++ b/tools/nsjail.Dockerfile @@ -0,0 +1,29 @@ +FROM rocker/r-ver:4.5.0 + +RUN apt-get update \ + && apt-get install --yes --no-install-recommends \ + autoconf \ + bison \ + build-essential \ + flex \ + git \ + libnl-route-3-dev \ + libprotobuf-dev \ + libtool \ + pkg-config \ + protobuf-compiler \ + && rm -rf /var/lib/apt/lists/* + +RUN git clone --depth 1 https://github.com/google/nsjail.git /opt/nsjail \ + && make --directory /opt/nsjail \ + && install --mode 0755 /opt/nsjail/nsjail /usr/local/bin/nsjail + +RUN R --vanilla -s -e 'install.packages(c( \ + "callr", "cli", "evaluate", "jsonlite", "later", "processx", "promises", "rlang" \ +), repos = "https://cloud.r-project.org")' + +WORKDIR /workspace +COPY R/run-r.R R/run-r.R +COPY tools/nsjail-smoke.R tools/nsjail-smoke.R + +CMD ["Rscript", "tools/nsjail-smoke.R"]