
Sandbox Híbrido — como evitar que uma IA exploda seu sistema
O problema
O TatuEngine tem um agente autopoiético com ToolUse — ele pode executar código, ler arquivos, escrever resultados. Se o agente for malicioso (ou alucinar), ele pode tentar:
../../etc/passwd— path traversal clássico~/.ssh/id_rsa— roubo de credenciais/proc/self/mem— acesso ao kernel- Escrever em
/etc/cron.d/— persistência
Eu precisava de um sistema que permitisse o agente trabalhar mas bloqueasse tudo que é perigoso, sem depender de container Docker ou VM.
A solução: Sandbox Híbrido, em Python puro, 3 zonas de isolamento, 511 linhas, 51 testes passando.
As 3 zonas
O sandbox define 3 zonas de segurança:
| Zona | Prefixos | Leitura | Escrita | Tamanho máx |
|---|---|---|---|---|
| SAFE | ~/projetos, ~/dev, ~/workspace, ~/.tatu, /tmp |
✅ | ✅ | 500 MB |
| RESTRICTED | ~, /mnt/d, /mnt/f, /mnt/c/Users |
✅ | ❌ | 100 MB |
| DENIED | .ssh/*, .git/*, *.key, *.pem, .env*, /etc, /sys |
❌ | ❌ | — |
# tatu/sandbox.py — linhas 131-152
DEFAULT_SAFE_PREFIXES = [
"~/projetos", "~/dev", "~/.tatu", "/tmp",
]
DEFAULT_RESTRICTED_PREFIXES = [
"~", "/mnt/d", "/mnt/f", "/mnt/c/Users",
]
DENIED_PATTERNS = [
".ssh/*", ".aws/*", ".git/*",
"*.key", "*.pem", ".env*",
"known_hosts", "authorized_keys",
]
A inteligência tá no match hierárquico: um path em ~/projetos cai em SAFE (leitura + escrita), mas ~/.ssh/id_rsa cai em DENIED mesmo estando dentro de ~.
Path traversal detection
O sandbox detecta 3 formas de ataque:
# Linhas 259-262
# Path traversal clássico
if ".." in path.split(os.sep):
return SandboxResult.err(f"Path traversal detectado: {path}")
# URL-encoded traversal
if "%2e" in path.lower():
return SandboxResult.err(f"Path traversal codificado: {path}")
# Byte nulo
if "\x00" in path:
return SandboxResult.err("Caminho contém byte nulo")
O resolvedor usa Path.resolve() (que segue symlinks e resolve ..), depois verifica se o path absoluto começa com um prefixo permitido:
# Linhas 194-200
def _is_safe_prefix(self, resolved: Path) -> bool:
r_str = str(resolved)
for raw in self._safe_prefixes:
expanded = str(Path(raw).expanduser())
if r_str == expanded or r_str.startswith(expanded + "/"):
return True
return False
Isso impede o truque de criar um symlink ~/projetos/link -> /etc e acessar via ele — o resolve() segue o symlink e revela o path real.
Zonas DENIED de sistema
Paths críticos são bloqueados mesmo que não estejam na lista de patterns:
# Linhas 222-238
def _check_denied_paths(self, resolved: Path) -> None:
dangerous = ["/etc", "/sys", "/proc", "/boot",
"/bin", "/sbin", "/lib", "/lib64"]
for d in dangerous:
if r_str == d or r_str.startswith(d + "/"):
raise SandboxError(f"Path em zona DENIED: {resolved} ({d})")
# /usr liberado só /usr/share
# /var liberado só /var/log
# /tmp liberado só /tmp/tatu
Escrita: só SAFE
O check_write() só permite escrita em prefixos SAFE:
# Linhas 282-295
if allow_write:
if self.level == "restricted":
return SandboxResult.err(
f"Escrita em sandbox RESTRICTED não permitida: {resolved}")
if not in_safe and not in_restricted:
return SandboxResult.err(f"Escrita fora de SAFE: {resolved}")
Isso significa que o agente pode criar arquivos em ~/projetos (SAFE), mas não pode modificar ~/.ssh (DENIED) nem escrever em /mnt/d (RESTRICTED).
A API pública
from tatu.sandbox import sandbox, safe_read, safe_write, safe_check_size
# Ler arquivo (RESTRICTED por padrão — só SAFE + RESTRICTED)
result = sandbox.check_read("~/projetos/teste.txt")
if result.ok:
path = result.path # Path já resolvido e validado
# Escrever (só SAFE)
result = sandbox.check_write("~/projetos/output.txt")
if result.ok:
with open(result.path, "w") as f:
f.write("dados seguros")
# Utilitários
safe_read("~/projetos/dados.csv") # lê + valida em 1 call
safe_read_binary("~/projetos/imagem.png")
safe_write("~/projetos/novo.txt", "conteudo")
safe_check_size("/tmp/bigfile.bin") # verifica tamanho máximo
Testes
O sandbox tem 439 linhas de testes em tests/test_sandbox.py. Testa:
- ✅ Leitura em SAFE
- ✅ Escrita em SAFE
- ❌ Path traversal (
../etc/passwd) - ❌ Path traversal URL-encoded (
%2e%2e) - ❌ Byte nulo no path
- ❌ Leitura de DENIED (
.git/config,.ssh/id_rsa) - ❌ Escrita em RESTRICTED
- ❌ Symlink apontando pra fora
- ❌ Path muito longo (>4096)
- ✅ Busca em diretório
- ✅ Arquivos aninhados em subdiretórios
# Exemplo real do test suite
def test_path_traversal_blocked(sandbox_with_prefixes):
result = sandbox_with_prefixes.check_read(
str(sandbox_with_prefixes.prefixes[0] / "../../etc/passwd")
)
assert not result.ok
assert "Path traversal" in result.error
O que aprendi
Path.resolve()é a chave — sem ele, symlinks viram backdoor. Com ele, qualquer tentativa de desviar do prefixo é neutralizada.- 3 zonas > 2 zonas — SAFE/RESTRICTED/DENIED é mais útil que só permitido/bloqueado. O agente pode ler configs do sistema (RESTRICTED) sem poder modificá-las.
- Pattern matching hierárquico —
~/projetosé SAFE, mas~/.sshé DENIED mesmo estando dentro de~. A ordem importa: SAFE > RESTRICTED > DENIED. - 511 linhas, 51 testes, zero containers — não precisa de Docker ou VM pra isolar uma IA. Python puro +
Path.resolve()+ pattern matching resolve 99% dos casos.