Todos os artigos

// Knowledge.log — 技術記事

Sandboxing de tool calls em coding agents: isolando filesystem e rede antes do PR

Isole tool calls de coding agents no filesystem e na rede com bubblewrap: teste de fuga reproduzível, evidência de kernel e o que verificar antes do PR.

Seu coding agent roda Bash, escreve arquivo, chama MCP — tudo isso com o mesmo usuário e a mesma sessão de rede do seu terminal. Se um tool call decidir (por conta própria ou porque um README.md malicioso do repositório injetou instrução) ler ~/.aws/credentials ou abrir uma conexão para fora, nada no processo impede isso. A permissão que você configurou é só uma checagem de string de comando antes de rodar; depois que o processo começa, ele tem o mesmo acesso que você.

O resultado esperado deste artigo é um teste que você roda uma vez e confia: um processo isolado em namespaces do kernel Linux, onde escrever fora da área liberada devolve EROFS, ler um segredo devolve ENOENT, e abrir uma conexão de rede devolve ENETUNREACH — não porque o modelo "se comportou", mas porque o kernel recusou.

Modelo de ameaça: por que isso não é o sandbox de CI de sempre

Um pipeline de CI roda um script fixo, conhecido de antemão, dentro de uma imagem descartável. O processo que executa é o pipeline — previsível, auditado em code review.

Um tool call de coding agent é dinâmico: o comando que vai rodar vem da saída do modelo, que por sua vez pode ter sido influenciada por conteúdo não confiável (um arquivo do repo, uma página web, um resultado de outra ferramenta). A validação de permissão baseada em string de comando — "esse Bash bate com o padrão liberado?" — é um gate de decisão, não um sandbox: ela roda antes do processo começar e não sabe o que o binário vai fazer depois de iniciar. Isso vale mesmo para arquiteturas mais rigorosas: o artigo sobre execute-verify-stop em servidor MCP descreve uma camada de política que recusa tools/call antes do efeito colateral — e essa camada continua útil, mas opera antes do processo nascer. Este artigo trata da camada que continua valendo depois: se a política falhar, for contornada, ou o modelo for enganado, o processo ainda está preso pelo namespace do kernel em que ele nasceu. Isso não é redundância — é defesa em profundidade em dois pontos diferentes da cadeia.

Um detalhe que vale declarar de cara: bubblewrap e contêineres compartilham o kernel do host. Não é uma VM. Um bug de kernel é, em teoria, uma via de escape — se a ameaça inclui exploração de kernel, a resposta certa é gVisor ou uma VM de verdade, não isolamento por namespace. Aqui a garantia que se afirma é mais estreita e mais verificável: este processo, com estes mounts e namespaces, recebeu EROFS/ENETUNREACH do kernel — não "o modelo não consegue escapar".

Pré-requisitos

  • Linux com namespaces de usuário sem privilégio habilitados (kernel.unprivileged_userns_clone=1).
  • bubblewrap 0.11.1 (bwrap --version). É o que foi executado para gerar a evidência deste artigo, em kernel 7.0.0-27-generic.
  • python3 disponível dentro do sandbox, só para o teste de rede (usa socket.create_connection).

Docker aparece aqui como referência documentada — os flags (--read-only, --tmpfs, --network none) vêm da documentação oficial, mas não foram executados neste host porque o Docker não está instalado nele. Trate a seção Docker como "os mesmos conceitos, outra ferramenta", não como resultado testado.

Filesystem: allowlist real, não --ro-bind / / e reza

bubblewrap começa com um mount namespace vazio — você monta cada pedaço da árvore que quer que o processo enxergue. Isso é bom porque força você a declarar o que existe, mas tem uma armadilha comum: montar --ro-bind / / para "ter o sistema inteiro disponível" e achar que isso é um sandbox. Não é. É o host inteiro, só que somente leitura — e leitura ainda inclui ~/.ssh, ~/.aws/credentials, qualquer coisa que o usuário consiga ler. Um allowlist de verdade cobre também o que não pode ser lido, não só o que não pode ser escrito.

A estrutura que separa leitura, escrita e segredo:

DEMO=/tmp/agent-sandbox-demo
rm -rf "$DEMO"
mkdir -p "$DEMO/repo" "$DEMO/scratch" "$DEMO/secret"
echo "public-code" > "$DEMO/repo/README.md"
echo "super-secret" > "$DEMO/secret/.env"
chmod 600 "$DEMO/secret/.env"

E o comando que monta o sandbox e abre um shell dentro dele (as sondas da próxima seção substituem esse /bin/sh final por comandos específicos):

DEMO=/tmp/agent-sandbox-demo
bwrap \
  --ro-bind / / \
  --dev /dev --proc /proc \
  --ro-bind "$DEMO/repo" "$DEMO/repo" \
  --bind "$DEMO/scratch" "$DEMO/scratch" \
  --tmpfs "$DEMO/secret" \
  --unshare-net --unshare-pid \
  --new-session --die-with-parent \
  --chdir "$DEMO/repo" \
  /bin/sh

Repare na ordem: o --ro-bind / / dá a base (tudo somente leitura), e cada bind seguinte sobrescreve um pedaço dela — repo continua RO, scratch vira RW, secret vira um tmpfs vazio que cobre o diretório real, escondendo o .env por completo em vez de só bloquear a escrita nele. Uma pegadinha que vale saber antes de gastar dez minutos com ela: --bind DEST exige que DEST já exista no lado esquerdo (aqui, dentro do --ro-bind / /); se você tentar montar em um caminho inexistente, o próprio mkdir de dentro do bwrap falha com "Read-only file system", porque o diretório-alvo simplesmente não está lá.

E sempre --new-session --die-with-parent: o primeiro evita o sequestro de terminal via TIOCSTI (CVE-2017-5226), o segundo garante que o processo filho não sobreviva órfão se o pai morrer.

O equivalente conceitual em Docker (documentado, não executado neste host):

docker run --rm \
  --read-only \
  --tmpfs /scratch \
  --network none \
  --cap-drop ALL \
  --security-opt no-new-privileges \
  -v "$DEMO/repo:/workspace:ro" \
  -v "$DEMO/scratch:/scratch:rw" \
  --user 1000:1000 \
  <imagem> ...

--cap-drop ALL tira, entre outras, NET_ADMIN; o Docker já derruba CAP_SYS_ADMIN por padrão, então mount de dentro do contêiner falha mesmo sem esse flag explícito.

Rede: negada por padrão, allowlist é outra peça

--unshare-net cria um netns novo com só loopback — nenhuma interface, nenhuma rota para fora. Não existe "porta liberada por padrão" para escapar: o kernel simplesmente não tem por onde rotear o pacote.

O ponto que costuma confundir: um allowlist de domínio (liberar api.github.com mas não o resto) não é um flag do bwrap. --unshare-net só sabe negar tudo ou (se você não usar o flag) deixar tudo passar pela interface do host — não existe meio-termo de "libera esses hosts" dentro do próprio namespace de rede. Esse controle fino vive em uma segunda peça, tipicamente um proxy fora do sandbox para onde o tráfego é redirecionado (Unix socket + allowlist de domínio no proxy, é o padrão que a documentação da Anthropic descreve para sandboxes de agente). E vale registrar a limitação honesta dessa peça: se o proxy não termina TLS, ele decide por SNI/nome de host — o que pode ser enganado por domain fronting. Allowlist de rede e negação de rede são duas garantias diferentes, com dois mecanismos diferentes.

Teste de fuga reproduzível

Cada sonda abaixo é uma invocação de bwrap separada — não um script único despejando saída combinada — reaproveitando os mesmos mounts da seção anterior via um array de shell:

DEMO=/tmp/agent-sandbox-demo
MOUNTS=(
  --ro-bind / /
  --dev /dev --proc /proc
  --ro-bind "$DEMO/repo" "$DEMO/repo"
  --bind "$DEMO/scratch" "$DEMO/scratch"
  --tmpfs "$DEMO/secret"
  --unshare-net --unshare-pid
  --new-session --die-with-parent
  --chdir "$DEMO/repo"
)

1. Escrever em README.md (esperado: EROFS)

bwrap "${MOUNTS[@]}" /bin/sh -c 'echo bad > README.md'; echo "exit=$?"
/bin/sh: 1: cannot create README.md: Read-only file system
exit=2

2. Escrever em scratch (esperado: ok)

bwrap "${MOUNTS[@]}" /bin/sh -c 'echo ok > /tmp/agent-sandbox-demo/scratch/out.txt'; echo "exit=$?"
exit=0

3. Ler o segredo sob tmpfs (esperado: ENOENT)

bwrap "${MOUNTS[@]}" cat /tmp/agent-sandbox-demo/secret/.env; echo "exit=$?"
cat: /tmp/agent-sandbox-demo/secret/.env: No such file or directory
exit=1

4. Escrever em /root/PWNED (esperado: EROFS)

bwrap "${MOUNTS[@]}" /bin/sh -c 'echo pwned > /root/PWNED'; echo "exit=$?"
/bin/sh: 1: cannot create /root/PWNED: Read-only file system
exit=2

5. Conectar em 1.1.1.1:443 de dentro do netns isolado (esperado: ENETUNREACH)

bwrap "${MOUNTS[@]}" python3 -c "import socket; socket.create_connection(('1.1.1.1',443),2); print('CONTROL_CONNECTED')"
OSError: [Errno 101] Network is unreachable

(traceback completo omitido — o que importa é errno 101, não a pilha de chamadas do socket.py.)

E, de fora do sandbox, o host confirma o que não mudou:

cat "$DEMO/repo/README.md"    # public-code — inalterado
cat "$DEMO/scratch/out.txt"   # ok — a escrita foi para a área liberada
ls /root/PWNED                # No such file or directory

Rodando a mesma sonda 5 sem --unshare-net (controle, mesmo payload, outro namespace):

bwrap --ro-bind / / --dev /dev --proc /proc --unshare-pid \
  --new-session --die-with-parent \
  python3 -c "import socket; socket.create_connection(('1.1.1.1',443),2); print('CONTROL_CONNECTED')"
CONTROL_CONNECTED

É a prova de que o bloqueio da sonda 5 veio do netns isolado, não de firewall externo ou de falta de conectividade da máquina.

Agora o controle negativo — o motivo de este artigo insistir tanto em "--ro-bind / / não é allowlist". Rode o mesmo comando de montagem, mas remova a linha --tmpfs "$DEMO/secret":

bwrap \
  --ro-bind / / \
  --dev /dev --proc /proc \
  --ro-bind /tmp/agent-sandbox-demo/repo /tmp/agent-sandbox-demo/repo \
  --bind /tmp/agent-sandbox-demo/scratch /tmp/agent-sandbox-demo/scratch \
  --unshare-net --unshare-pid \
  --new-session --die-with-parent \
  --chdir /tmp/agent-sandbox-demo/repo \
  /bin/sh -c 'cat /tmp/agent-sandbox-demo/secret/.env; echo "NEGATIVE_LEAK=$?"'

Saída:

super-secret
NEGATIVE_LEAK=0

O .env estava só sob --ro-bind / /, sem cobertura de tmpfs. Ele não foi escrito — mas foi lido, com sucesso, exit=0. É exatamente o tipo de bug de configuração que passa em teste de fumaça ("o sandbox não deixou escrever nada!") e falha em produção assim que alguém pede pro agente exfiltrar um segredo em vez de apagá-lo.

Verificando isolamento no nível certo

O critério de sucesso não é "o processo filho saiu com código zero" — um shell continua rodando comandos mesmo depois que um deles falha, então "exit 0 no fim" não prova nada sobre o meio do caminho. O critério é estado do host + errno: o README.md continua public-code? O arquivo apareceu em scratch e só lá? A conexão TCP devolveu 101 e não um timeout de rede lenta?

Isso também é o que separa "o sandbox bloqueou" de "o modelo não tentou": rodar o mesmo payload duas vezes, uma dentro do isolamento e outra fora (os controles acima), é o teste que efetivamente atribui o bloqueio ao kernel. Sem o controle, um ENETUNREACH pode ser só a rede do CI caída naquele minuto.

Remediação concreta: trate --ro-bind / / como o ponto de partida perigoso que é — todo caminho que pode conter segredo (~/.aws, ~/.ssh, .env, tokens de CI) precisa de um --tmpfs explícito por cima, não apenas ausência de --bind de escrita. E todo destino extra de --bind/--tmpfs precisa existir antes de montar, ou o bwrap falha ao tentar criá-lo.

Observação segura para produção: não logue o conteúdo do processo isolado (pode conter os próprios segredos que você está protegendo). Capture bwrap --info-fd — ele emite um JSON com PID do filho e namespaces criados — como artefato por execução, e rode a checagem de host state + errno acima como asserção automática no pipeline, não como algo que um humano confere manualmente de vez em quando.

Armadilhas

  • --ro-bind / / sozinho não esconde segredo nenhum — é allowlist de escrita, não de leitura. Cubra com --tmpfs ou não monte / inteiro.
  • --bind/--tmpfs em cima de --ro-bind / / exige que o destino já exista; senão a mensagem de erro ("Read-only file system") confunde com bloqueio intencional.
  • Socket do Docker (/var/run/docker.sock) montado dentro do sandbox é acesso total ao host, isolamento nenhum — a própria documentação da Anthropic lista isso como bypass conhecido.
  • Proxy de allowlist de domínio sem terminação TLS decide por nome de host anunciado, não pelo destino real; domain fronting engana esse tipo de filtro.
  • Nenhum número de performance foi medido aqui — não existe claim de latência ou throughput neste artigo, e não deveria existir sem carga real.

Próximo passo

Rode o teste de fuga (com o controle negativo) no ambiente onde o agente realmente vai executar Bash — não só neste host de demonstração — antes de confiar nisso em CI. Se qualquer sonda devolver um resultado diferente do daqui, o sandbox ainda não está pronto para ficar entre o agente e o seu repositório.

ai-agentslinuxdevex

// Continue.training — 次のステップ

Conhecimento só conta quando vira prática.

Volte ao artigo, execute os exemplos e compartilhe o que aprendeu.

Explorar mais artigos