Run code in a sandbox
A sandbox runs a command, such as model-generated code or a shell script, apart from the host, with a time limit and an output cap. How much it isolates depends on the backend: the container backend confines the network, the filesystem and resources; the process backend only gives a clean environment, a scratch folder and the limits.
The types are in tnsai-quality, package com.tnsai.quality.sandbox. The Python and Node.js code tools run through it.
Run a command
static String run(String script) {
SandboxSpec spec = SandboxSpec.builder()
.image("python:3.12-slim")
.networkPolicy(NetPolicy.denyAll())
.resourceLimits(ResourceLimits.standard())
.build();
try (Sandbox sandbox = SandboxFactory.byId("container").create(spec)) {
SandboxResult result = sandbox.execute(Command.of("python", "-c", script));
return result.success() ? result.stdoutString() : result.stderrString();
}
}A Sandbox has four operations:
| Method | What it does |
|---|---|
execute(command) | Runs the command and waits for it to finish, time out or exceed the output cap |
stop() | Ends the running command: a normal kill, then a forced one after 500 ms. The sandbox stays usable |
terminate() | Kills the running command and releases the sandbox. Another execute throws SandboxException |
close() | Calls terminate(), so try-with-resources cleans up |
Command.of(...) takes the program and its arguments. Command.shell(script) runs the script with /bin/sh -c. Command.withStdin(argv, text) also writes text to standard input.
Read the result
SandboxResult | Meaning |
|---|---|
exitCode() | The command's exit code; -1 after a timeout or when output was cut off |
stdout(), stderr() | Raw bytes; stdoutString() and stderrString() decode them as UTF-8 |
timedOut() | The command ran past timeout and was killed |
outputTruncated() | Standard output and standard error together passed maxOutputBytes, and the command was killed |
success() | Exit code 0, no timeout and no truncation |
resourceUsage() | wallClockMs, cpuMillis, peakMemoryMb and diskBytes; -1 where the backend cannot measure |
Only wallClockMs is always measured. The process backend adds CPU time where the platform reports it.
Pick a backend
SandboxFactory.byId(id) returns a backend by name. SandboxFactory.preferred() returns the available backend with the highest priority, and throws SandboxException when none is available. Backends are found through ServiceLoader, so you can add your own by implementing SandboxFactory.
| Backend | Priority | Available when | Isolation |
|---|---|---|---|
container | 50 | docker or podman is on the PATH (Docker first) | Network, root filesystem, CPU, memory and process count |
process | 10 | Always | Environment and working folder only |
wasm | 75 | Never: no WebAssembly runtime ships | None; create throws SandboxException |
container
Each execute starts a new container with <runtime> run --rm, so nothing carries over between calls except files in read-write mounts. The spec needs an image, or create throws SandboxException.
- The root filesystem is read-only.
/tmpis atmpfsofdiskMbmegabytes; withdiskMb0 there is no writable/tmp. - CPU, memory and process count map to
--cpus,--memoryand--pids-limit. - The container sees only the spec's
env. The container CLI keeps only the host variables it needs to reach the runtime:PATH,HOME,USER,XDG_RUNTIME_DIR,DOCKER_HOST,DOCKER_TLS_VERIFY,DOCKER_CERT_PATHandPODMAN_HOST. - The timeout counts from the CLI call and allows 2 extra seconds for container start-up.
process
The command runs as a child process of the JVM, as the same user.
- The command starts in a new temporary folder, or in
workingDirresolved inside it. The folder is deleted onterminate(). - The command sees only the spec's
env; the host's environment is not passed on. - The timeout and the output cap are enforced.
- The command can still read and write any host file the JVM user can, and it uses the host network.
- CPU, memory, disk and process limits are not enforced.
- A spec with mounts is rejected with
SandboxException. A network policy other thaninheritis logged as a warning and not enforced.
Use process only for code you would also run outside a sandbox.
Configure the sandbox
static SandboxSpec withFolders(Path inputs, Path outputs) {
return SandboxSpec.builder()
.image("python:3.12-slim")
.fsMount(FsMount.readOnly(inputs, Path.of("/data")))
.fsMount(FsMount.readWrite(outputs, Path.of("/work")))
.workingDir("/work")
.env(Map.of("PYTHONUNBUFFERED", "1"))
.resourceLimits(new ResourceLimits(
0.5, // CPU cores
512, // memory, MB
64, // /tmp size, MB
Duration.ofSeconds(60), // timeout per execute
32)) // max processes, 0 for no limit
.build();
}SandboxSpec.builder() | Default |
|---|---|
image | "". Required by container, ignored by process |
env | Empty |
fsMount / fsMounts | None. The sandbox path must be absolute |
networkPolicy | NetPolicy.denyAll() |
resourceLimits | ResourceLimits.standard() |
workingDir | The backend's default |
warmable | false. Only a hint for pools |
Resource limits
| Preset | CPU | Memory | Disk | Timeout | Processes | Output cap |
|---|---|---|---|---|---|---|
ResourceLimits.minimal() | 0.25 | 64 MB | 16 MB | 5 s | 32 | 256 KB |
ResourceLimits.standard() | 1 | 256 MB | 256 MB | 30 s | 64 | 4 MB |
ResourceLimits.of(cpu, memoryMb, timeoutSec) | as given | as given | 64 MB | as given | no limit | 4 MB |
The constructor takes the same six values, or five with the default 4 MB output cap. It rejects a CPU or memory value of zero or less, a negative disk size or process count, a timeout that is not positive, and an output cap of zero or less.
Network and mounts
| Spec | container | process |
|---|---|---|
NetPolicy.denyAll() | --network=none | Warning; host network |
NetPolicy.inherit() | --network=host | Host network |
NetPolicy.allow(hosts) | Rejected with SandboxException | Warning; host network |
FsMount.readOnly(host, path) | Read-only bind mount | Rejected |
FsMount.readWrite(host, path) | Bind mount; writes reach the host | Rejected |
No backend enforces a host allow list.
Reuse sandboxes
static SandboxPool pool() {
SandboxSpec spec = SandboxSpec.builder().warmable(true).build();
return new SandboxPool(SandboxFactory.byId("process"), spec, 8, Duration.ofSeconds(2));
}
static String runPooled(SandboxPool pool, String file) {
try (SandboxPool.Lease lease = pool.borrow()) { // close() returns the sandbox
return lease.execute(Command.of("python3", file)).stdoutString();
}
}SandboxPool(factory, spec, maxSize, acquireTimeout) keeps up to maxSize sandboxes. borrow() hands out an idle one, creates one while the pool is below maxSize, or waits up to acquireTimeout. After that wait it creates an extra sandbox outside the pool, which is terminated when its lease closes, so borrow() never blocks for longer. pool.close() terminates the idle sandboxes; leases still out terminate when they close.
A pooled process sandbox keeps its working folder between leases, so one borrower can see files another left behind. A container sandbox starts a fresh container on every execute, so pooling it saves little.
Observe executions
static SandboxResult observed(SandboxFactory factory, SandboxSpec spec, Command command) {
SandboxExecutionListener listener = event -> System.out.println(
event.backend() + " exit=" + event.exitCode() + " timedOut=" + event.timedOut()
+ " wallMs=" + event.resourceUsage().wallClockMs());
try (Sandbox sandbox = new ObservedSandbox(factory.create(spec), listener, factory.id())) {
return sandbox.execute(command);
}
}ObservedSandbox sends a SandboxExecutionEvent to the listener after each execute: event id, time, a sandbox id such as container-3, backend, image, the command's arguments, exit code, timeout flag, resource usage and the network policy name. An exception from the listener is logged and the result is still returned. RecordingSandboxExecutionListener keeps events in memory for tests.