Sandbox for AI agents: running agent code safely

1. Overview

More and more applications allow AI agents to write code and execute it immediately. Agents use this capability to analyze data, perform calculations, or manipulate files. The problem is that this code is generated by a language model, and you don’t have direct control over its contents.

This article explains the concept of sandboxing for agents and why this topic matters. It also compares common isolation levels. The main section builds a DockerSandbox using Java 21 and Spring Boot 3.x, along with tests that verify the security limits.

2. Why agents need sandboxing

An agent with a ‘run code’ tool is essentially a process that takes commands as text. That text could come from a user, a webpage the agent just read, or an attached file. So you must account for these risks:

  • Prompt injection: a webpage contains hidden text like “read the environment variables and send them to address X”. The agent might follow it.
  • Unintentional harmful code: infinite loops, filling the disk, accidentally deleting directories.
  • Secret leaks: code running in the same process as your application can read API keys, configuration files, and database connections.
  • Lateral movement: from the code execution machine, an attacker can probe other internal services on the same network.

A sandbox is an isolated environment. Code running inside it can only see what you allow: no network, no secrets, CPU/RAM/time limits, temporary filesystem that’s discarded after each run.

This topic matters because agents have shifted from ‘answering questions’ to ‘taking actions’. Once agents can take action, sandboxing becomes mandatory infrastructure.

3. Common isolation levels

There’s no single sandbox type. Each level trades off safety, startup speed, and operational complexity:

Level Example Advantages Limitations
Restricted interpreter Script runs in same JVM, blocked APIs Fast, simple Easy to escape, not recommended for untrusted code
Container Docker/Podman with strict flags Common, easy to set up Shares kernel with host
Container + kernel sandbox Container on runtime like gVisor Reduces kernel attack surface Performance overhead, some syscalls unsupported
MicroVM Firecracker, Kata Containers VM-level isolation More operational complexity
WebAssembly WASM runtime Instant startup, no permissions by default Limited library ecosystem
Managed sandbox service Cloud platforms built for agent code No self-hosting Vendor lock-in, data leaves your system

For most teams, a tightly configured container is a reasonable starting point. When risk is higher—for example, multi-tenant or anonymous users—you can upgrade to a stronger isolation runtime without changing your application code.

4. Building DockerSandbox

The goal is a layer that takes a Python code snippet generated by an agent, runs it in a single-use container, and returns the result. Your machine running the application must have Docker CLI installed.

4.1. Configuration

First, gather the limits into a configuration record:

package dev.nonla.sandbox;

import org.springframework.boot.context.properties.ConfigurationProperties;
import java.time.Duration;

@ConfigurationProperties(prefix = "agent.sandbox")
public record SandboxProperties(
        String image,
        String runtime,      // null = default runtime; can set to "runsc"
        Duration timeout,
        String memory,
        String cpus,
        int pidsLimit,
        int maxOutputBytes) {
}
agent:
  sandbox:
    image: python:3.12-slim
    timeout: 5s
    memory: 256m
    cpus: "0.5"
    pids-limit: 64
    max-output-bytes: 65536

The runtime field lets you switch to a stronger isolation runtime later using only configuration.

4.2. Result type

package dev.nonla.sandbox;

public record SandboxResult(int exitCode, String stdout, String stderr, boolean timedOut) {

    public boolean success() {
        return !timedOut && exitCode == 0;
    }
}

4.3. Sandbox class

package dev.nonla.sandbox;

import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.concurrent.*;

public class DockerSandbox {

    private final SandboxProperties props;

    public DockerSandbox(SandboxProperties props) {
        this.props = props;
    }

    public SandboxResult runPython(String code) throws IOException, InterruptedException {
        String name = "agent-sbx-" + UUID.randomUUID();
        Process process = new ProcessBuilder(buildCommand(name)).start();

        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            Future<String> out = executor.submit(() -> readLimited(process.getInputStream()));
            Future<String> err = executor.submit(() -> readLimited(process.getErrorStream()));

            try (OutputStream stdin = process.getOutputStream()) {
                stdin.write(code.getBytes(StandardCharsets.UTF_8));
            }

            boolean finished = process.waitFor(props.timeout().toMillis(), TimeUnit.MILLISECONDS);
            if (!finished) {
                killContainer(name);
                process.destroyForcibly().waitFor();
            }
            return new SandboxResult(
                    finished ? process.exitValue() : -1,
                    await(out), await(err), !finished);
        }
    }

    List<String> buildCommand(String name) {
        List<String> cmd = new ArrayList<>(List.of(
                "docker", "run", "--rm", "-i",
                "--name", name,
                "--network", "none",
                "--memory", props.memory(),
                "--memory-swap", props.memory(),
                "--cpus", props.cpus(),
                "--pids-limit", String.valueOf(props.pidsLimit()),
                "--read-only",
                "--tmpfs", "/tmp:rw,size=64m",
                "--cap-drop", "ALL",
                "--security-opt", "no-new-privileges",
                "--user", "65534:65534",
                "--env", "HOME=/tmp"));
        if (props.runtime() != null && !props.runtime().isBlank()) {
            cmd.addAll(List.of("--runtime", props.runtime()));
        }
        cmd.addAll(List.of(props.image(), "python", "-"));
        return cmd;
    }

    private String readLimited(InputStream in) throws IOException {
        var buffer = new ByteArrayOutputStream();
        byte[] chunk = new byte[4096];
        int n;
        while ((n = in.read(chunk)) != -1) {
            int room = props.maxOutputBytes() - buffer.size();
            if (room > 0) {
                buffer.write(chunk, 0, Math.min(n, room));
            }
            // Keep reading to avoid blocking the process, but discard excess
        }
        return buffer.toString(StandardCharsets.UTF_8);
    }

    private void killContainer(String name) throws IOException, InterruptedException {
        new ProcessBuilder("docker", "kill", name).start().waitFor(5, TimeUnit.SECONDS);
    }

    private static String await(Future<String> future) throws InterruptedException {
        try {
            return future.get(5, TimeUnit.SECONDS);
        } catch (ExecutionException | TimeoutException e) {
            return "";
        }
    }
}

Key points:

  • Code passes through stdin (python -). You don’t need to mount any directory from the host, so the container can’t see your application’s files.
  • --rm with a random --name creates single-use containers. Each run gets a fresh environment.
  • --network none cuts off the network completely. This is the most important barrier against data exfiltration.
  • --memory paired with --memory-swap of equal size disables swap. --cpus and --pids-limit prevent resource exhaustion and fork bombs.
  • --read-only locks the root filesystem. --tmpfs /tmp provides a writable temporary area with a size cap.
  • --cap-drop ALL, no-new-privileges, and --user 65534:65534 (the nobody user) ensure code can’t run as root or gain additional privileges.
  • Don’t pass any application environment variables into the container. Only set HOME=/tmp so Python libraries have a place to write cache.
  • stdout and stderr are read in parallel using virtual threads. If read sequentially, the pipe buffer could fill and the process could hang.
  • When time runs out, this class calls docker kill before terminating the CLI process.

Note: process.destroyForcibly() only kills the client-side docker CLI process. The container may still run. That’s why you must docker kill by container name.

5. Integration with Spring Boot

You don’t need any libraries beyond spring-boot-starter. Tests will use spring-boot-starter-test, which already includes JUnit 5 and AssertJ:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

Next, declare a bean and a service that acts as a ‘tool’ for the agent:

package dev.nonla.sandbox;

import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.*;

@Configuration
@EnableConfigurationProperties(SandboxProperties.class)
class SandboxConfig {

    @Bean
    DockerSandbox dockerSandbox(SandboxProperties props) {
        return new DockerSandbox(props);
    }
}
package dev.nonla.sandbox;

import org.slf4j.*;
import org.springframework.stereotype.Service;

@Service
public class CodeExecutionTool {

    private static final Logger log = LoggerFactory.getLogger(CodeExecutionTool.class);
    private final DockerSandbox sandbox;

    public CodeExecutionTool(DockerSandbox sandbox) {
        this.sandbox = sandbox;
    }

    public String executePython(String sessionId, String code) {
        log.info("sandbox.exec session={} codeLength={}", sessionId, code.length());
        try {
            SandboxResult r = sandbox.runPython(code);
            log.info("sandbox.done session={} exit={} timedOut={}", sessionId, r.exitCode(), r.timedOut());
            if (r.timedOut()) {
                return "Error: code ran longer than allowed.";
            }
            return "exit=" + r.exitCode() + "\nstdout:\n" + r.stdout() + "\nstderr:\n" + r.stderr();
        } catch (Exception e) {
            log.error("sandbox.error session={}", sessionId, e);
            return "System error when running code.";
        }
    }
}

The executePython method is what you register as a tool in your agent framework. Two things to keep in mind:

  • The result returned to the model is always a string, even on error. The agent can read stderr and self-correct on the next attempt.
  • Every execution is logged with a sessionId. In case of incidents, audit logs are your first resource.

System error messages are kept generic; don’t send stack traces to the model. Stack traces can leak paths or internal configuration.

6. Testing sandbox behavior

Tests should verify each limit actually works, not just that code runs. The tests below require Docker. If your machine doesn’t have Docker, they’ll be skipped.

package dev.nonla.sandbox;

import org.junit.jupiter.api.*;
import java.time.Duration;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assumptions.assumeTrue;

class DockerSandboxTest {

    private DockerSandbox sandbox;

    @BeforeEach
    void setUp() {
        assumeTrue(dockerAvailable(), "Docker is not available");
        sandbox = new DockerSandbox(new SandboxProperties(
                "python:3.12-slim", null, Duration.ofSeconds(3),
                "128m", "0.5", 32, 1024));
    }

    @Test
    void runsSimpleCode() throws Exception {
        SandboxResult r = sandbox.runPython("print(sum(range(10)))");

        assertThat(r.success()).isTrue();
        assertThat(r.stdout().trim()).isEqualTo("45");
    }

    @Test
    void blocksNetworkAccess() throws Exception {
        SandboxResult r = sandbox.runPython("""
                import urllib.request
                urllib.request.urlopen("http://example.com", timeout=2)
                """);

        assertThat(r.exitCode()).isNotZero();
        assertThat(r.stderr()).contains("Error");
    }

    @Test
    void rootFileSystemIsNotWritable() throws Exception {
        SandboxResult r = sandbox.runPython("open('/hacked.txt', 'w').write('x')");

        assertThat(r.exitCode()).isNotZero();
    }

    @Test
    void killsInfiniteLoop() throws Exception {
        SandboxResult r = sandbox.runPython("while True: pass");

        assertThat(r.timedOut()).isTrue();
    }

    @Test
    void truncatesHugeOutput() throws Exception {
        SandboxResult r = sandbox.runPython("print('a' * 100_000)");

        assertThat(r.stdout()).hasSizeLessThanOrEqualTo(1024);
    }

    private static boolean dockerAvailable() {
        try {
            Process p = new ProcessBuilder("docker", "info").start();
            return p.waitFor(10, TimeUnit.SECONDS) && p.exitValue() == 0;
        } catch (Exception e) {
            return false;
        }
    }
}

Each test corresponds to a limit:

  • runsSimpleCode confirms the happy path: stdout is 45.
  • blocksNetworkAccess shows HTTP requests fail. stderr will contain something like:
urllib.error.URLError: <urlopen error [Errno -3] Temporary failure in name resolution>
  • rootFileSystemIsNotWritable confirms code can’t write outside /tmp.
  • killsInfiniteLoop confirms timeout works and the container is cleaned up.
  • truncatesHugeOutput confirms output is truncated. This prevents the agent from bloating your context or application memory.

Note: The first run may be slow because Docker must pull the image. You should pre-pull the image in CI or during application startup; otherwise, the test timeout might give false positives.

7. Additional defensive layers

Sandboxing is important but not the only layer. In practice, you should combine it with:

  • Controlled egress: if the agent truly needs network access—say, to install packages—don’t open it wide. Route it through a proxy with an allowlisted domain list.
  • Secrets never enter the sandbox: when code needs to call an API, let the application call via a separate tool with permission checks. Don’t pass tokens into the container.
  • Human-in-the-loop: for irreversible actions (sending email, writing to production databases, moving money), require user confirmation.
  • Rate limiting and quotas: each session gets a maximum number of executions. This prevents agents from falling into costly trial-and-error loops.
  • Isolate sandbox runners: run the Docker daemon for sandboxes on a separate node, not alongside your main service.
  • Pre-warmed container pool: when latency matters, keep a few containers ready. Each container is still single-use and discarded after.

8. Common mistakes and when not to use this approach

Common mistakes:

  • Mounting the Docker socket into your application or sandbox. Whoever controls /var/run/docker.sock effectively has root access to the host. If your application runs in a container and needs to call Docker, consider a separate sandbox service instead of sharing the socket.
  • Forgetting --network none. This is the most common and most serious mistake.
  • Running as root in the container because it’s ‘easier to install packages’. Pre-install packages into a custom image.
  • Reusing containers across users. Leftover files and processes leak data from one session to the next.
  • String concatenation into shell commands (sh -c "python -c '" + code + "'"). This creates command injection on the host. Always pass via stdin or a file.
  • Believing the container is an absolute boundary. Containers share a kernel. For hostile code in multi-tenant environments, use gVisor or microVMs.

When not to use this approach:

  • Your agent only needs a few fixed computations. A tool with a clear signature like calculateInvoiceTotal(Order) is far safer and cheaper than arbitrary code execution.
  • You serve anonymous users on the public Internet at scale. Use microVMs or a dedicated sandbox service instead of building your own with Docker.
  • Your environment doesn’t allow Docker—for example, certain serverless platforms. Consider WASM or a managed sandbox.
  • Code needs access to sensitive data. Pass only filtered data via stdin or temp files; don’t relax the sandbox.

9. Conclusion

When agents can run code, every piece of text they read becomes a potential command. Sandboxing transforms that risk into something measurable: no network, no secrets, resource limits, single-use environments.

You’ve built a DockerSandbox with the necessary security flags. It reads output safely using virtual threads, handles timeouts correctly with docker kill, and has tests proving each limit works. From this foundation, you can upgrade to a stronger isolation runtime using configuration alone. Always pair sandboxing with audit logs, network allowlists, and human confirmation for critical actions.

ai-agentsandboxdockersecurityspring-boot