Filesystem, rede, processo, credencial e tempo — o que isolar quando o código a ser rodado foi escrito por um modelo
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.
Vale ser específico, porque generalidade aqui atrapalha.
| Cenário | Probabilidade | Impacto | Contenção principal |
|---|---|---|---|
| código com bug entra em loop infinito | alta | médio | limite de CPU e tempo |
| código escreve fora do diretório de trabalho | média | alto | filesystem read-only + tmpfs |
| instrução em input faz o agente exfiltrar dados | média | crítico | egressa negada por padrão |
| dependência instalada em runtime traz pacote malicioso | média | crítico | sem rede + imagem pré-construída |
| agente usa credencial ampla demais para uma ação destrutiva | média | crítico | escopo mínimo + aprovação |
| execução consome recurso e derruba vizinhos | média | alto | cgroups 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.
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"},
}
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.
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.
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.
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.
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;
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ê.
noexec.no-new-privileges e perfil seccomp estão aplicados.none por padrão; allowlist explícita quando necessária.169.254.169.254 e as faixas internas estão bloqueados.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.
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.
Produtos gratuitos e pagos para transformar ideias em uma base que você consegue executar.
13 produtos disponíveisContinue explorando tópicos similares
Seis meses depois, alguém pergunta por que o sistema classificou aquele caso assim. Se a resposta depende de lembrar qual prompt estava no ar, você não tem sistema auditável — tem folclore.
Preço por token é o número do fornecedor. Custo por tarefa concluída é o seu. Entre os dois existem retries, tarefas que falham, contexto que cresce e um nível de esforço que ninguém varre.
Se você não consegue desligar a IA sem derrubar a funcionalidade principal, o boundary está no lugar errado. Este artigo mostra como descobrir isso antes do incidente.
Checklist de 47 pontos para encontrar bugs, riscos de segurança e problemas de performance antes do lançamento.
Templates testados em produção, usados por desenvolvedores. Economize semanas de setup no seu próximo projeto.