1

Sandbox para agentes que executam código: as cinco fronteiras que importam (2026 edicao)

English summary: How to isolate agent-generated code execution — the five containment boundaries, resource limits, deny-by-default egress, credential scoping, and the threat model that changes once the agent's input is untrusted.

Publicado: 22 de setembro de 2026
Tempo de leitura: 17 min
Palavras-chave: segurança, sandbox, agentes, isolamento, contêiner, seccomp


Um agente que escreve e executa código é, do ponto de vista de segurança, uma máquina de execução remota de código com boas intenções.

O incômodo não é o modelo ser malicioso. É que o input dele não é seu. Um ticket de suporte, um arquivo enviado, um trecho de página raspada — qualquer um desses pode conter texto que altera o que o agente decide executar. Entre "o modelo é confiável" e "o que entra nele é confiável" existe um abismo, e a maior parte das arquiteturas de agente trata os dois como a mesma coisa.

A boa notícia é que o problema já tem solução conhecida. Executar código não confiável é algo que a indústria faz há décadas — CI, runners de concurso de programação, plataformas de notebook. Este artigo aplica esse repertório ao caso do agente, em cinco fronteiras concretas.

Nada aqui é exótico: é contenção padrão, aplicada num lugar onde muita gente ainda não aplica.

TL;DR

  • O modelo de ameaça não é "o modelo virou hostil". É "a entrada do modelo foi controlada por outra pessoa".
  • Cinco fronteiras: filesystem, rede, processo, credencial e tempo. Deixar uma aberta anula boa parte das outras.
  • Egressa de rede deve ser negada por padrão, com allowlist explícita. É a fronteira mais esquecida.
  • O contêiner do agente não deve conter nenhuma credencial que você não aceite perder.
  • Limites de CPU, memória, PIDs e disco evitam que uma negação de serviço acidental derrube o host.
  • Toda execução é registrada: o que rodou, o que tentou alcançar, o que produziu.
  • Aprovação humana para operações irreversíveis não é fallback — é design.

O modelo de ameaça, escrito com precisão

Vale ser específico, porque generalidade aqui atrapalha.

CenárioProbabilidadeImpactoContenção principal
código com bug entra em loop infinitoaltamédiolimite de CPU e tempo
código escreve fora do diretório de trabalhomédiaaltofilesystem read-only + tmpfs
instrução em input faz o agente exfiltrar dadosmédiacríticoegressa negada por padrão
dependência instalada em runtime traz pacote maliciosomédiacríticosem rede + imagem pré-construída
agente usa credencial ampla demais para uma ação destrutivamédiacríticoescopo mínimo + aprovação
execução consome recurso e derruba vizinhosmédiaaltocgroups e limite de PIDs

Note que nenhuma linha diz "o modelo decidiu atacar". Todas são consequências de entrada não confiável, bug ou permissão excessiva — as mesmas três causas de sempre.

Fronteira 1: filesystem

A regra: tudo somente leitura, exceto um diretório efêmero.

CONTAINER_CONFIG = {
    "image": "agent-sandbox:3.12-slim",     # imagem fixa, sem gerenciador de pacote
    "read_only": True,                       # raiz imutável
    "tmpfs": {
        "/workspace": "size=256m,mode=1777,noexec",
        "/tmp": "size=64m,mode=1777,noexec",
    },
    "user": "65534:65534",                   # nobody, nunca root
    "cap_drop": ["ALL"],                     # nenhuma capability
    "security_opt": [
        "no-new-privileges:true",            # bloqueia escalada por setuid
        "seccomp=profiles/agent-seccomp.json",
    ],
}

Três detalhes que valem mais do que parecem.

noexec no tmpfs. Sem isso, o agente pode escrever um binário em /workspace e executá-lo, contornando qualquer restrição que você tenha aplicado ao interpretador.

no-new-privileges. Impede que um binário setuid dentro da imagem sirva de degrau para escalada.

Imagem sem gerenciador de pacotes. Se pip e apt não existem, "instalar uma dependência" deixa de ser um vetor. As bibliotecas permitidas entram na build, revisadas como qualquer outra dependência.

Um detalhe operacional que evita frustração: monte a entrada como somente leitura e colete a saída de um caminho específico. O agente não deve poder reescrever o próprio input.

volumes = {
    str(input_dir):  {"bind": "/input",  "mode": "ro"},
    str(output_dir): {"bind": "/output", "mode": "rw"},
}

Fronteira 2: rede — a mais esquecida

Esta é a fronteira que a maioria das implementações deixa aberta, e a que transforma um bug em incidente de dados.

Sem contenção de egressa, qualquer conteúdo que o agente processou pode ser enviado para fora em uma linha de código. O caminho não exige malícia do modelo: basta que o input contenha uma instrução convincente.

O padrão correto é network_mode: none para a maioria dos casos:

def sandbox_config(needs_network: bool = False) -> dict:
    base = {**CONTAINER_CONFIG}
    if not needs_network:
        base["network_mode"] = "none"        # o padrão
    else:
        base["network_mode"] = "agent-egress"  # rede com proxy e allowlist
    return base

Quando rede é genuinamente necessária, ela passa por um proxy com allowlist — e o allowlist é de domínios, não de "internet".

ALLOWED_HOSTS = {
    "pypi.org",
    "files.pythonhosted.org",
    "api.interno.empresa.com",
}

BLOCKED_CIDRS = [
    "169.254.169.254/32",   # metadata de instância cloud: sempre bloquear
    "10.0.0.0/8",           # rede interna
    "172.16.0.0/12",
    "192.168.0.0/16",
    "127.0.0.0/8",
]

O endereço 169.254.169.254 merece destaque próprio. É o endpoint de metadados das principais nuvens, e alcançá-lo de dentro de um contêiner costuma render credenciais de instância. Bloqueá-lo explicitamente é obrigatório, mesmo quando você acha que a rede já está restrita.

E resolva DNS no proxy, não no contêiner. Sem isso, exfiltração por consulta DNS continua possível mesmo com HTTP bloqueado.

Fronteira 3: processo e recursos

Limites de recurso protegem contra o caso mais provável de todos: código com bug.

RESOURCE_LIMITS = {
    "mem_limit": "512m",
    "memswap_limit": "512m",    # igual à memória: desabilita swap
    "nano_cpus": 1_000_000_000, # 1 CPU
    "pids_limit": 64,           # contém fork bomb
    "ulimits": [
        {"Name": "nofile", "Soft": 256, "Hard": 256},
        {"Name": "fsize", "Soft": 50_000_000, "Hard": 50_000_000},  # 50 MB
    ],
}

pids_limit é o que impede que um while True: fork() — trivial de gerar por acidente — consuma a tabela de processos do host. memswap_limit igual a mem_limit garante que o limite de memória seja real, e não contornado por swap.

E um timeout no nível do orquestrador, não apenas dentro do código:

import subprocess

def run_sandboxed(code: str, timeout_s: int = 30) -> ExecutionResult:
    container = docker.containers.run(
        **sandbox_config(), **RESOURCE_LIMITS,
        command=["python", "-I", "-S", "/input/script.py"],  # isolado, sem site
        detach=True,
    )
    try:
        status = container.wait(timeout=timeout_s)
        return ExecutionResult(
            exit_code=status["StatusCode"],
            stdout=container.logs(stdout=True, stderr=False)[:100_000],
            stderr=container.logs(stdout=False, stderr=True)[:100_000],
            timed_out=False,
        )
    except requests.exceptions.ReadTimeout:
        container.kill()
        return ExecutionResult(exit_code=-1, stdout=b"", stderr=b"", timed_out=True)
    finally:
        container.remove(force=True)          # nunca reutilize contêiner

Truncar stdout e stderr importa: um agente que imprime em loop pode encher o disco de logs do host, que é uma negação de serviço pela porta dos fundos.

E nunca reutilize contêiner entre execuções. Estado residual de uma execução visível na seguinte é um canal lateral gratuito.

Fronteira 4: credencial

A pergunta que organiza esta fronteira: se o conteúdo deste contêiner vazar inteiro, o que eu perco?

Se a resposta inclui qualquer chave de produção, o desenho está errado.

def build_env(task: Task) -> dict[str, str]:
    """Nenhum segredo entra. Só o necessário para a tarefa rodar."""
    return {
        "PATH": "/usr/local/bin:/usr/bin:/bin",
        "HOME": "/workspace",
        "PYTHONDONTWRITEBYTECODE": "1",
        "TASK_ID": task.id,
        # sem AWS_*, sem DATABASE_URL, sem API keys
    }

Quando o agente precisa de fato de uma operação privilegiada — consultar um banco, chamar uma API interna —, ele não recebe a credencial. Ele recebe uma ferramenta que executa a operação do lado de fora, com escopo mínimo:

@tool(name="query_tickets", read_only=True)
def query_tickets(status: str, limit: int = 50) -> list[dict]:
    """O agente chama isto. A credencial nunca entra no sandbox."""
    if status not in {"open", "closed", "pending"}:
        raise ValueError(f"status inválido: {status}")
    limit = min(limit, 200)
    with read_only_pool.connection() as conn:       # usuário com SELECT apenas
        return conn.execute(
            "SELECT id, subject, status FROM ticket WHERE status = %s LIMIT %s",
            (status, limit),
        ).fetchall()

Três propriedades nesse desenho: a credencial vive fora do sandbox, a query é parametrizada e fixa (não é SQL gerado pelo modelo), e o usuário do banco tem permissão de leitura apenas. O agente escolhe parâmetros, não instruções.

Essa inversão é o coração da coisa. Dar ao modelo a capacidade de executar SQL arbitrário com uma credencial ampla é entregar a fronteira. Dar uma função com assinatura tipada e validação é manter o controle onde ele pertence.

Fronteira 5: tempo, e o que exige humano

Nem toda operação deve ser automatizável, por mais confiável que o agente pareça.

IRREVERSIBLE = {
    "delete_records", "send_email", "post_payment",
    "deploy", "rotate_credential", "modify_permissions",
}


def execute_tool(agent_id: str, tool: str, args: dict) -> ToolResult:
    if tool in IRREVERSIBLE:
        approval = request_human_approval(
            agent_id=agent_id, tool=tool, args=args,
            preview=render_preview(tool, args),     # mostra o efeito, não o pedido
            expires_in_s=900,
        )
        if not approval.granted:
            return ToolResult(status="denied", reason=approval.reason)

    audit.record(agent_id=agent_id, tool=tool, args=redact(args))
    return TOOLS[tool](**args)

O campo preview é o detalhe que faz a aprovação funcionar. Mostrar "o agente quer chamar delete_records" produz aprovação automática por cansaço. Mostrar "isto vai apagar 1.284 registros, sendo 12 criados hoje" produz uma decisão.

E aprovação expira. Um pedido aprovado quarenta minutos depois pode estar operando sobre um estado diferente daquele que o humano avaliou.

Observabilidade: registre o que foi tentado, não só o que passou

O log de sandbox mais útil não é o das execuções bem-sucedidas. É o das tentativas bloqueadas.

@dataclass
class ExecutionAudit:
    execution_id: str
    agent_id: str
    task_id: str
    code_hash: str                  # hash, não o código, por volume e privacidade
    exit_code: int
    timed_out: bool
    duration_ms: int
    peak_memory_mb: int
    network_attempts: list[str]     # hosts alcançados ou negados
    blocked_syscalls: list[str]     # o que o seccomp barrou
    files_written: list[str]

network_attempts e blocked_syscalls são sinais de alta qualidade. Uma tentativa de conectar a 169.254.169.254 não é ruído — é um indicador de que alguma coisa, no prompt ou no input, empurrou o agente naquela direção. Alerte nisso.

SELECT agent_id, unnest(network_attempts) AS host, count(*)
FROM execution_audit
WHERE created_at > now() - interval '1 day'
  AND NOT (unnest(network_attempts) = ANY(%(allowed)s))
GROUP BY 1, 2 ORDER BY 3 DESC;

Testando a contenção

Contenção não testada é contenção presumida. Estes testes são rápidos e devem rodar em CI:

@pytest.mark.security
@pytest.mark.parametrize("code,expectativa", [
    ("open('/etc/passwd','w').write('x')",           "PermissionError"),
    ("import socket; socket.create_connection(('1.1.1.1',53))", "network"),
    ("import urllib.request; urllib.request.urlopen('http://169.254.169.254/')", "network"),
    ("import os; os.fork()",                          "pids_limit"),
    ("x = 'a' * (10**10)",                            "MemoryError"),
    ("while True: pass",                              "timeout"),
    ("import subprocess; subprocess.run(['pip','install','x'])", "not found"),
])
def test_contencao(code: str, expectativa: str):
    result = run_sandboxed(code, timeout_s=5)
    assert result.exit_code != 0 or result.timed_out, (
        f"contenção falhou para: {code}"
    )

Sete linhas, sete fronteiras verificadas. Se alguma passar, você descobriu algo importante antes de um incidente descobrir por você.

Checklist

  • Raiz do contêiner é somente leitura; escrita só em tmpfs com noexec.
  • Processo roda como usuário não privilegiado, com todas as capabilities removidas.
  • no-new-privileges e perfil seccomp estão aplicados.
  • A imagem não contém gerenciador de pacotes.
  • Rede é none por padrão; allowlist explícita quando necessária.
  • 169.254.169.254 e as faixas internas estão bloqueados.
  • DNS é resolvido no proxy, não no contêiner.
  • Memória, CPU, PIDs e tamanho de arquivo têm limites.
  • Timeout existe no orquestrador, não só no código.
  • Contêineres nunca são reutilizados entre execuções.
  • Nenhum segredo de produção entra no ambiente do sandbox.
  • Operações privilegiadas são ferramentas tipadas fora do sandbox.
  • Operações irreversíveis exigem aprovação com preview e expiração.
  • Tentativas de rede bloqueadas geram alerta.
  • Os testes de contenção rodam em CI.

Conclusão

Isolar a execução de um agente não é um gesto de desconfiança em relação ao modelo. É reconhecer uma propriedade simples da arquitetura: a entrada do agente vem de fora, e portanto a saída dele é não confiável por construção.

As cinco fronteiras são padrão, e nenhuma delas é nova. O que muda com agentes é a frequência: código não confiável deixou de ser um evento raro de CI e virou o caminho principal de uma funcionalidade.

Se você lembrar de apenas uma coisa, que seja a rede. Filesystem somente leitura e limite de memória protegem contra bug, que é o caso mais provável. Egressa negada por padrão protege contra exfiltração, que é o caso mais caro — e é a fronteira que fica aberta com mais frequência, porque ela não quebra nada enquanto está errada.

Leituras relacionadas

Os exemplos usam Docker e Python. Para isolamento mais forte, considere microVMs (Firecracker, gVisor) — o modelo de fronteiras é o mesmo, com um limite de contenção mais rígido no nível do kernel.

Carregando publicação patrocinada...