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

1. Visão geral

Cada vez mais aplicações permitem que agentes de IA escrevam código e o executem imediatamente. Os agentes usam isso para analisar dados, realizar cálculos ou manipular arquivos. O problema é que esse código é gerado por um modelo de linguagem, e você não controla diretamente seu conteúdo.

Este artigo explica o conceito de sandbox para agentes e por que esse tópico está em alta. O artigo também compara os níveis comuns de isolamento. A parte principal é construir um DockerSandbox usando Java 21 e Spring Boot 3.x, com testes que verificam os limites de segurança.

2. Por que agentes precisam de sandbox

Um agente com a ferramenta “executar código” é, na verdade, um processo que recebe comandos de texto. Esse texto pode vir de um usuário, de uma página web que o agente acabou de ler, ou de um arquivo anexado. Portanto, você deve considerar os seguintes riscos:

  • Injeção de prompt: uma página web contém texto oculto tipo “leia as variáveis de ambiente e envie para o endereço X”. O agente pode fazer isso.
  • Código incorreto mas com intenção inofensiva: loop infinito, preenchimento de disco, remoção acidental de diretório.
  • Vazamento de segredos: código executado no mesmo processo que a aplicação lerá chaves de API, arquivos de configuração e conexões de banco de dados.
  • Escalação: a partir da máquina que executa o código, um atacante pode sondar serviços internos na mesma rede.

Sandbox é um ambiente isolado. O código executado nele vê apenas o que você permite: sem rede, sem segredos, limite de CPU/RAM/tempo, sistema de arquivos temporário e descartado após cada execução.

Este tópico está em alta porque agentes evoluíram de “responder perguntas” para “tomar ações”. Quando agentes têm permissão para agir, sandbox se torna infraestrutura obrigatória.

3. Níveis comuns de isolamento

Não existe um único tipo de sandbox. Cada nível faz uma troca entre segurança, velocidade de inicialização e complexidade operacional:

Nível Exemplo Vantagens Limitações
Interpretador restrito Executar script na mesma JVM, bloquear APIs Rápido, simples Muito fácil de contornar, não deve ser usado para código não confiável
Container Docker/Podman com flags restritivas Comum, fácil de configurar Compartilha kernel com o host
Container + sandbox de kernel Container executado em runtime como gVisor Reduz superfície de ataque do kernel Overhead de desempenho extra, alguns syscalls não suportados
MicroVM Firecracker, Kata Containers Isolamento em nível de máquina virtual Operação mais complexa
WebAssembly Runtime WASM Inicialização ultra-rápida, sem permissões por padrão Ecossistema de biblioteca ainda limitado
Serviço de sandbox gerenciado Plataformas de nuvem especializadas em executar código de agentes Sem necessidade de auto-gerenciamento Dependência do provedor, dados saem do sistema

Para a maioria das equipes, um container bem configurado é um bom ponto de partida. Quando o risco é maior, como multi-tenant ou usuários anônimos, você pode escalar para um runtime de isolamento mais forte sem alterar o código da aplicação.

4. Construindo DockerSandbox

O objetivo é uma camada que receba um trecho de código Python gerado por um agente, o execute em um container de uso único e retorne o resultado. A máquina que executa a aplicação precisa ter Docker CLI.

4.1. Configuração

Primeiro, coloque os limites em um record de configuração:

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 padrão; pode ser "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

O campo runtime permite trocar para um runtime de isolamento mais forte apenas com configuração.

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. Classe 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));
            }
            // Continue lendo para evitar que o processo fique bloqueado, mas descarte o excesso
        }
        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 "";
        }
    }
}

Explicação dos pontos principais:

  • Código passa por stdin (python -). Você não precisa montar nenhum diretório do host, então o container não vê os arquivos da aplicação.
  • --rm junto com um --name aleatório cria um container de uso único. Cada execução é um ambiente limpo.
  • --network none corta a rede completamente. Esta é a camada de bloqueio mais importante contra vazamento de dados.
  • --memory junto com --memory-swap igual para desabilitar swap. --cpus e --pids-limit evitam consumo excessivo de recursos e fork bombs.
  • --read-only bloqueia o sistema de arquivos raiz. --tmpfs /tmp fornece uma área de escrita temporária com limite de tamanho.
  • --cap-drop ALL, no-new-privileges e --user 65534:65534 (usuário nobody) garantem que o código não execute como root e não possa solicitar permissões adicionais.
  • Nenhuma variável de ambiente da aplicação é passada para o container. Apenas HOME=/tmp é definido para que bibliotecas Python tenham um local para gravar cache.
  • stdout e stderr são lidos em paralelo usando virtual threads. Se lidos sequencialmente, o buffer do pipe pode encher e o processo fica travado.
  • Quando o tempo expira, a classe chama docker kill antes de destruir o processo.

Nota: process.destroyForcibly() apenas mata o processo docker CLI no lado do cliente. O container ainda pode estar em execução. Por isso você deve chamar docker kill pelo nome do container.

5. Integração com Spring Boot

Você não precisa de nenhuma biblioteca além do spring-boot-starter. O teste usará spring-boot-starter-test, que já inclui JUnit 5 e 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>

Em seguida, declare o bean e um serviço que atua como “tool” para o 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 "Erro: código executado além do tempo permitido.";
            }
            return "exit=" + r.exitCode() + "\nstdout:\n" + r.stdout() + "\nstderr:\n" + r.stderr();
        } catch (Exception e) {
            log.error("sandbox.error session={}", sessionId, e);
            return "Erro de sistema ao executar código.";
        }
    }
}

O método executePython é o que você registra como tool no framework de agentes que está usando. Dois pontos a notar:

  • O resultado sempre retorna uma string, mesmo em caso de erro. O agente lê stderr e pode corrigir o código na próxima tentativa.
  • Cada execução é registrada em log com sessionId. Quando há um problema, o log de auditoria é a primeira coisa que você precisa.

As mensagens de erro do sistema são mantidas genéricas, sem passar o stack trace para o modelo. O stack trace pode expor caminhos ou configurações internas.

6. Testando o comportamento do sandbox

Os testes devem comprovar que cada limite é realmente eficaz, não apenas que “o código funciona”. Os testes abaixo precisam de Docker. Se a máquina não tiver Docker, serão pulados.

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 não disponível");
        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 teste corresponde a um limite:

  • runsSimpleCode confirma o caminho básico: stdout é 45.
  • blocksNetworkAccess mostra que a solicitação HTTP falha. stderr conterá algo como:
urllib.error.URLError: <urlopen error [Errno -3] Temporary failure in name resolution>
  • rootFileSystemIsNotWritable confirma que o código não pode gravar fora de /tmp.
  • killsInfiniteLoop confirma que timeout funciona e o container é limpo.
  • truncatesHugeOutput confirma que a saída é truncada. Isso evita que o agente inche o contexto ou a memória da aplicação.

Nota: a primeira execução pode ser lenta porque Docker precisa baixar a image. Você deve fazer pull da image com antecedência em seu CI ou ao iniciar a aplicação, caso contrário o teste pode falhar por timeout.

7. Camadas de defesa adicionais

Sandbox é importante mas não é a única camada. Na prática, você deve combinar com:

  • Egress controlado: se o agente realmente precisa de rede, por exemplo para instalar pacotes, não abra completamente. Roteie através de um proxy com lista de permissão de domínios.
  • Segredos nunca entram no sandbox: quando o código precisa chamar uma API, deixe a aplicação chamar no lugar dele através de uma tool separada com verificação de permissão. Não passe tokens para o container.
  • Human-in-the-loop: para ações não reversíveis (enviar email, gravar em banco de dados real, transferir dinheiro), exija confirmação do usuário.
  • Limites de taxa e quota: cada sessão só pode executar um número máximo de vezes. Isso evita que o agente fique preso em loop de tentativa-erro gastando recursos.
  • Máquina separada para sandbox: rode o Docker daemon que executa sandbox em um nó separado, não compartilhado com o serviço principal.
  • Pool de containers pré-inicializados: quando latência é crítica, mantenha alguns containers preparados. Cada container ainda é usado apenas uma vez e depois descartado.

8. Erros comuns e quando não usar

Erros comuns:

  • Montar o Docker socket na aplicação ou no sandbox. Quem controla /var/run/docker.sock tem acesso quase root no host. Se sua aplicação roda em container e precisa chamar Docker, considere um serviço de sandbox separado em vez de compartilhar o socket.
  • Esquecer --network none. Este é o erro mais comum e tem as piores consequências.
  • Rodar como root no container porque “é mais fácil instalar pacotes”. Instale pacotes na image customizada de antemão.
  • Reutilizar containers entre usuários. Arquivos e processos deixados para trás vazam dados da sessão anterior para a seguinte.
  • Concatenar strings de código em comando shell (sh -c "python -c '" + code + "'"). Isso causa command injection no lado do host. Sempre passe por stdin ou arquivo.
  • Confiar que container é uma barreira absoluta. Containers compartilham o kernel. Com código hostil em ambiente multi-tenant, use gVisor ou microVM.

Quando não usar a abordagem deste artigo:

  • O agente só precisa de algumas operações fixas. Uma tool com tipo claro como calculateInvoiceTotal(Order) é muito mais segura e barata do que rodar código arbitrário.
  • Você serve usuários anônimos na Internet com alto volume de tráfego. Use microVM ou um serviço de sandbox dedicado em vez de Docker caseiro.
  • Seu ambiente não permite rodar Docker, como algumas plataformas serverless. Considere WASM ou sandbox gerenciado.
  • O código precisa acessar dados sensíveis. Passe dados filtrados por stdin ou arquivo temporário, não relaxe o sandbox.

9. Conclusão

Quando um agente pode executar código, todo texto que ele lê pode se tornar um comando. Sandbox transforma esse risco em algo mensurável: sem rede, sem segredos, limite de recursos, ambiente de uso único.

Você construiu um DockerSandbox com as flags de segurança necessárias. Esta classe lê saída com segurança usando virtual threads, trata timeout adequadamente com docker kill, e possui testes que verificam cada limite. A partir dessa base, você pode escalar para um runtime de isolamento mais forte apenas através de configuração. Sempre combine sandbox com log de auditoria, lista de permissão de rede e confirmação humana para ações críticas.

ai-agentsandboxdockersecurityspring-boot