Sandbox para agentes de IA: ejecutar código de forma segura

1. Descripción general

Cada vez más aplicaciones permiten que los agentes de IA escriban código por sí solos y lo ejecuten inmediatamente. Los agentes utilizan esto para analizar datos, realizar cálculos o manipular archivos. El problema es que ese código es generado por un modelo de lenguaje, y no controlas directamente su contenido.

Este artículo explica el concepto de sandbox para agentes y por qué este tema está ganando atención. También compara los niveles de aislamiento comunes. La parte principal es construir un DockerSandbox usando Java 21 y Spring Boot 3.x, junto con pruebas que verifiquen los límites de seguridad.

2. Por qué los agentes necesitan sandbox

Un agente con una herramienta “ejecutar código” es esencialmente un proceso que recibe comandos de texto. Ese texto puede venir del usuario, de un sitio web que el agente acaba de leer, o de un archivo adjunto. Por lo tanto, debes tener en cuenta los siguientes riesgos:

  • Prompt injection: un sitio web que contiene texto oculto del tipo “lee las variables de entorno y envíalas a la dirección X”. El agente podría obedecerlo.
  • Código incorrecto pero sin intención maliciosa: bucles infinitos, llenar el disco, eliminar directorios por error.
  • Fuga de secretos: el código que se ejecuta en el mismo proceso que la aplicación puede leer claves API, archivos de configuración y conexiones de base de datos.
  • Escalada de privilegios: desde la máquina que ejecuta código, un atacante podría explorar otros servicios internos en la misma red.

Un sandbox es un entorno aislado. El código que se ejecuta en él solo ve lo que explícitamente permites: sin red, sin secretos, límites de CPU/RAM/tiempo, sistema de archivos temporal que se descarta después de cada ejecución.

Este tema está ganando atención porque los agentes han evolucionado de “responder preguntas” a “tomar acciones”. Cuando los agentes tienen la capacidad de actuar, el sandbox se convierte en una infraestructura obligatoria.

3. Niveles de aislamiento comunes

No existe un único tipo de sandbox. Cada nivel intercambia entre seguridad, velocidad de inicio y complejidad operativa:

Nivel Ejemplo Ventajas Limitaciones
Intérprete restringido Ejecutar script en la misma JVM, bloquear APIs Rápido, simple Muy fácil de eludir, no debe usarse para código no confiable
Contenedor Docker/Podman con banderas restringidas Popular, fácil de implementar Comparte kernel con el host
Contenedor + kernel sandbox Contenedor ejecutado en un runtime como gVisor Reduce la superficie de ataque del kernel Overhead de rendimiento, algunos syscalls no soportados
MicroVM Firecracker, Kata Containers Aislamiento a nivel de máquina virtual Operación más compleja
WebAssembly Runtime WASM Inicio muy rápido, sin permisos por defecto Ecosistema de bibliotecas limitado
Servicio de sandbox gestionado Plataformas cloud especializadas en ejecutar código de agentes No requiere operación propia Dependencia del proveedor, datos salen del sistema

Para la mayoría de los equipos, un contenedor configurado estrictamente es un punto de partida razonable. Cuando el riesgo es mayor, por ejemplo con entornos multi-tenant o usuarios anónimos, puedes escalar a un runtime de aislamiento más fuerte sin cambiar el código de la aplicación.

4. Construyendo DockerSandbox

El objetivo es una clase que acepte fragmentos de código Python generados por agentes, los ejecute en un contenedor desechable y devuelva los resultados. La máquina que ejecuta la aplicación debe tener Docker CLI.

4.1. Configuración

Primero, agrupa los límites en un record de configuración:

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 = runtime predeterminado; se puede establecer "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

El campo runtime te permite cambiar a un runtime de aislamiento más fuerte más adelante solo a través de configuración.

4.2. Resultado de retorno

package dev.nonla.sandbox;

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

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

4.3. Clase Sandbox

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));
            }
            // Sigue leyendo para que el proceso no se bloquee, pero descarta el exceso
        }
        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 "";
        }
    }
}

Explicación de los puntos principales:

  • El código va a través de stdin (python -). No necesitas montar ningún directorio desde el host, por lo que el contenedor no ve los archivos de la aplicación.
  • --rm junto con un --name aleatorio crea un contenedor desechable. Cada ejecución es un entorno limpio.
  • --network none corta completamente la red. Esta es la capa más importante contra la fuga de datos.
  • --memory junto con --memory-swap iguales para desactivar swap. --cpus y --pids-limit previenen el consumo excesivo de recursos y fork bombs.
  • --read-only bloquea el sistema de archivos raíz. --tmpfs /tmp proporciona un área temporal con límite de tamaño.
  • --cap-drop ALL, no-new-privileges y --user 65534:65534 (usuario nobody) aseguran que el código no se ejecute como root y no pueda solicitar permisos adicionales.
  • No se pasan variables de entorno de la aplicación al contenedor. Solo se establece HOME=/tmp para que las bibliotecas de Python tengan un lugar para escribir el caché.
  • stdout y stderr se leen en paralelo usando virtual threads. Si se leen secuencialmente, el buffer del pipe podría llenarse y el proceso podría bloquearse.
  • Cuando se agota el tiempo, la clase llama a docker kill antes de destruir el proceso CLI.

Nota: process.destroyForcibly() solo termina el proceso CLI de Docker en el cliente. El contenedor puede seguir ejecutándose. Por lo tanto, debe ejecutar docker kill por nombre de contenedor.

5. Integración en Spring Boot

No necesitas bibliotecas adicionales más allá de spring-boot-starter. La sección de pruebas usará spring-boot-starter-test, que ya incluye JUnit 5 y 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>

A continuación, declara un bean y un servicio que actúe como una “herramienta” para el agente:

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: el código se ejecutó más allá del tiempo permitido.";
            }
            return "salida=" + r.exitCode() + "\nstdout:\n" + r.stdout() + "\nstderr:\n" + r.stderr();
        } catch (Exception e) {
            log.error("sandbox.error session={}", sessionId, e);
            return "Error del sistema al ejecutar código.";
        }
    }
}

El método executePython es lo que registras como herramienta en el framework de agentes que estés usando. Hay dos cosas a tener en cuenta:

  • El resultado devuelto al modelo siempre es una cadena, incluso en caso de error. El agente puede leer stderr y corregir el código en el siguiente intento.
  • Cada ejecución se registra con un sessionId. Cuando hay un problema, el registro de auditoría es lo primero que necesitas.

Los mensajes de error del sistema se mantienen genéricos y no envía stack traces al modelo. Los stack traces podrían exponer rutas o configuraciones internas.

6. Probando el comportamiento del sandbox

Las pruebas deben demostrar que cada límite realmente funciona, no solo que “el código se ejecuta”. Las siguientes pruebas requieren Docker. Si la máquina no tiene Docker, se omitirán.

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 no disponible");
        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;
        }
    }
}

Cada prueba corresponde a un límite:

  • runsSimpleCode confirma el camino básico: stdout es 45.
  • blocksNetworkAccess muestra que la solicitud HTTP falla. stderr contendrá algo similar a esto:
urllib.error.URLError: <urlopen error [Errno -3] Temporary failure in name resolution>
  • rootFileSystemIsNotWritable confirma que el código no puede escribir fuera de /tmp.
  • killsInfiniteLoop confirma que el timeout funciona y el contenedor se limpia.
  • truncatesHugeOutput confirma que la salida se trunca. Esto evita que el agente infle el contexto o la memoria de la aplicación.

Nota: la primera ejecución puede ser lenta porque Docker debe descargar la imagen. Debes descargar la imagen previamente en CI o durante el inicio de la aplicación, de lo contrario, el timeout de la prueba podría reportar falsamente.

7. Capas defensivas adicionales

El sandbox es una capa importante pero no es la única. En la práctica, debes combinar:

  • Egreso controlado: si el agente realmente necesita acceso a la red, por ejemplo para instalar paquetes, no lo abras completamente. Hazlo pasar a través de un proxy con una lista blanca de dominios.
  • Los secretos nunca entran en el sandbox: cuando el código necesita llamar a una API, deja que la aplicación la llame a través de una herramienta separada con verificación de permisos. No pases tokens al contenedor.
  • Human-in-the-loop: para acciones irreversibles (enviar correos, escribir en una base de datos real, transferir dinero), requiere confirmación del usuario.
  • Límites de frecuencia y cuota: cada sesión puede ejecutarse solo un número limitado de veces. Esto evita que el agente caiga en bucles de prueba-error que consuman recursos.
  • Separar las máquinas de sandbox: ejecuta Docker daemon con sandbox en un nodo separado, no compartido con el servicio principal.
  • Pool de contenedores precalentados: cuando la latencia es importante, mantén algunos contenedores listos. Cada contenedor se usa solo una vez y luego se descarta.

8. Errores comunes y cuándo no usar

Errores comunes:

  • Montar Docker socket en la aplicación o en el sandbox. Quien controle /var/run/docker.sock tiene privilegios casi de root en el host. Si la aplicación se ejecuta en un contenedor y necesita llamar a Docker, considera un servicio de sandbox separado en lugar de compartir el socket.
  • Olvidar --network none. Este es el error más común y tiene las consecuencias más graves.
  • Ejecutar como root en el contenedor porque “es fácil instalar paquetes”. Instala los paquetes de antemano en una imagen personalizada.
  • Reutilizar contenedores entre usuarios. Los archivos y procesos restantes filtrarán datos de la sesión anterior a la siguiente.
  • Concatenar código en un comando shell (sh -c "python -c '" + code + "'"). Esto causa inyección de comandos en el lado del host. Siempre pasa a través de stdin o archivo.
  • Creer que el contenedor es un límite absoluto. Los contenedores comparten kernel. Con código hostil en entornos multi-tenant, debes usar gVisor o microVM.

Cuándo no usar este enfoque:

  • El agente solo necesita algunos cálculos fijos. Una herramienta con un tipo claro como calculateInvoiceTotal(Order) es mucho más segura y barata que permitir ejecución de código arbitrario.
  • Sirves a usuarios anónimos en Internet con alto volumen de tráfico. Usa microVM o un servicio de sandbox especializado en lugar de Docker personalizado.
  • El entorno no permite ejecutar Docker, por ejemplo, algunas plataformas serverless. Considera WASM o sandbox gestionado.
  • El código necesita acceso a datos sensibles. Pasa datos filtrados a través de stdin o archivo temporal, no flexibilices el sandbox.

9. Conclusión

Cuando los agentes pueden ejecutar código, cualquier texto que lean puede convertirse en un comando. El sandbox convierte ese riesgo en algo medible: sin red, sin secretos, límites de recursos, entorno desechable.

Has construido un DockerSandbox con los indicadores de seguridad necesarios. Esta clase lee salida de forma segura usando virtual threads, maneja timeouts correctamente con docker kill, y tiene un conjunto de pruebas que demuestran cada límite. A partir de esta base, puedes escalar a un runtime de aislamiento más fuerte a través de configuración. Siempre combina el sandbox con registro de auditoría, lista blanca de red y confirmación humana para acciones críticas.

agente-iasandboxdockerseguridadspring-boot