Blog

Sandbox de ferramentas: limitar o que o agente pode executar de verdade

A ferramenta que você entregou ao agente executa. Não importa quantas linhas do prompt digam "só consulte pedidos do próprio cliente" nem quão claro esteja o "nunca cancele sem confirmação": o texto é uma sugestão estatística, e o código que roda depois dela é uma execução real. Todo agente com tool use acaba descobrindo isso do jeito difícil, normalmente quando um cliente colou no chat um identificador que não era dele e o agente consultou com prazer, ou quando uma instrução plantada num documento recuperado convenceu o modelo a chamar a ferramenta de reembolso. O erro de fundo não é o modelo ter obedecido, é o sistema ter permitido. Este artigo trata do que fica entre a intenção do modelo e o efeito no mundo: como classificar ferramenta por efeito e não por nome, por que a autorização precisa ser reavaliada no ponto de execução com a identidade do cliente e não a do serviço, como validar o argumento contra o esquema e depois contra a realidade, como colocar teto de consumo em ferramenta que chama código externo, e como testar que a fuga não passa em vez de torcer para o prompt segurar.

2026-08-06 / IA Aplicada / 14 min

01

O prompt não é um mecanismo de segurança

A instrução no system prompt influencia a probabilidade de o modelo pedir uma ação, e é só isso que ela faz. Ela não impede a chamada, não valida o argumento e não acontece no momento em que a ferramenta roda. Entre a decisão do modelo e o efeito no mundo existe um trecho de código seu, e é exatamente esse trecho que decide se o pedido vira execução. Tratar o prompt como controle de acesso é o equivalente a validar formulário só no JavaScript do navegador: funciona para o usuário bem-intencionado e não funciona para nenhum outro.

O que torna o caso do agente pior que o do formulário é que a entrada hostil não precisa vir do cliente. Ela vem do trecho recuperado do RAG, do corpo de um e-mail que o agente foi ler, do retorno de uma ferramenta anterior, de um campo de descrição de produto que alguém preencheu meses atrás. Tudo isso entra no contexto com o mesmo status de texto que a instrução original, e o modelo não tem um canal separado que distinga "isto é dado" de "isto é ordem". A defesa não pode morar no mesmo lugar que o ataque. Ela mora na camada que executa, que é código determinístico e não muda de ideia porque um documento pediu com educação.

Onde a decisao vira efeito

  modelo                 camada de execucao              mundo
    |                           |                          |
    |-- tool_call(name, args) ->|                          |
    |                           | 1. ferramenta existe?    |
    |                           | 2. permitida neste       |
    |                           |    contexto/flag?        |
    |                           | 3. args validam no       |
    |                           |    schema?               |
    |                           | 4. o CLIENTE pode este   |
    |                           |    recurso? (nao o       |
    |                           |    servico)              |
    |                           | 5. dentro do orcamento   |
    |                           |    e do rate limit?      |
    |                           | 6. efeito colateral      |
    |                           |    exige confirmacao?    |
    |                           |------ executa ---------->|
    |<-- resultado ou erro -----|                          |

  o prompt influencia apenas a seta 1 (o pedido).
  as etapas 1..6 sao codigo, e sao elas que decidem.

02

Classificar ferramenta por efeito, não por nome

Antes de decidir o que sandboxar, é preciso saber o que cada ferramenta faz de verdade, e o nome mente com frequência. Uma ferramenta chamada `buscarPedido` que aceita um identificador arbitrário é, na prática, uma ferramenta de leitura de qualquer pedido da base. Uma chamada `atualizarPreferencia` que grava num campo consumido pelo motor de cobrança é uma ferramenta financeira. O critério útil é o efeito: o que muda no mundo se ela rodar, e o que acontece se ela rodar duas vezes.

ClasseEfeitoExemplosControle mínimo
Leitura escopadaNenhum efeito externo, mas expõe dadoConsultar pedido, ler histórico, buscar na base de conhecimentoEscopo forçado pelo servidor: o identificador do cliente nunca vem do argumento
Escrita reversívelMuda estado, dá para desfazerAtualizar endereço, marcar ticket, adicionar nota internaAutorização por identidade do cliente, chave de idempotência, registro em trilha
Escrita irreversívelMuda estado sem volta ou com custoCancelar assinatura, emitir reembolso, enviar mensagem ao cliente finalConfirmação explícita, limite por janela, aprovação humana acima de um valor
Execução de códigoRoda algo que você não escreveuInterpretar expressão, gerar e rodar consulta, executar script de cálculoProcesso isolado, sem rede, sem disco, com teto de CPU e memória e timeout
Chamada externaFala com um terceiro em nome do clienteConsultar transportadora, integrar com ERP, disparar webhookAllowlist de destino, timeout, orçamento de chamadas por conversa

A classificação não é burocracia: ela define o custo de errar e, portanto, quanto controle vale a pena. Leitura escopada erra e vaza dado. Escrita irreversível erra e o cliente perde a assinatura. Execução de código erra e você tem um interpretador arbitrário rodando dentro do seu processo, que é a maneira mais rápida de transformar um bug de produto num incidente de segurança. Vale escrever a classe junto da definição da ferramenta, não num documento à parte, porque é a definição que o código lê para decidir o que exigir.

03

Autorização acontece na execução, com a identidade do cliente

A falha mais comum e mais silenciosa é a ferramenta que recebe o identificador do recurso como argumento e confia nele. O modelo preenche esse campo a partir do contexto, e o contexto inclui tudo que o cliente escreveu. Basta o cliente dizer "consulte o pedido 8842" para o agente consultar o pedido 8842, que pode ser de outra pessoa. Não houve nenhum ataque sofisticado: a ferramenta simplesmente não perguntou se quem estava na conversa podia ver aquilo.

A correção é estrutural e cabe em duas regras. A primeira: o identificador de quem está pedindo nunca vem do argumento, vem do estado da conversa, que foi estabelecido na autenticação e o modelo não consegue tocar. A segunda: a autorização é reavaliada no ponto de execução, com essa identidade, contra o recurso concreto. Não basta ter checado no início da conversa, porque o vínculo entre cliente e recurso pode ter mudado, e principalmente porque a ferramenta não sabe qual recurso seria pedido lá atrás. Um detalhe frequentemente esquecido: as credenciais que o executor usa contra os sistemas internos costumam ser de serviço, com permissão ampla, e é justamente por isso que a checagem por cliente precisa acontecer antes, no seu código.

// agent/tool-runtime.js
// A execucao da ferramenta e o ponto onde a autorizacao acontece.
// Nada aqui confia no argumento para saber QUEM esta pedindo.

import { z } from 'zod';

const registry = new Map();

export function defineTool({ name, effect, schema, requiresConfirmation = false, handler }) {
  // 'effect' e obrigatorio: e ele que o runtime usa para decidir o rigor.
  if (!['read', 'write_reversible', 'write_irreversible', 'exec', 'external'].includes(effect)) {
    throw new Error('Ferramenta ' + name + ' sem classe de efeito valida');
  }
  registry.set(name, { name, effect, schema, requiresConfirmation, handler });
}

export async function runToolCall(call, session) {
  const tool = registry.get(call.name);
  // Ferramenta desconhecida vira erro tratavel, nao excecao: o modelo
  // pode alucinar um nome e o certo e ele replanejar, nao o turno cair.
  if (!tool) {
    return { ok: false, error: 'unknown_tool', message: 'Ferramenta inexistente.' };
  }
  if (!session.allowedTools.has(tool.name)) {
    return { ok: false, error: 'tool_not_allowed', message: 'Ferramenta indisponivel aqui.' };
  }

  // Validacao de forma. Argumento fora do schema nunca chega ao handler.
  const parsed = tool.schema.safeParse(call.arguments);
  if (!parsed.success) {
    return {
      ok: false,
      error: 'invalid_arguments',
      // Devolver o motivo permite ao modelo corrigir no proximo turno.
      message: parsed.error.issues.map((i) => i.path.join('.') + ': ' + i.message).join('; '),
    };
  }

  // Autorizacao de dominio: a identidade vem da SESSAO, nunca dos args.
  const decision = await authorize({
    customerId: session.customerId,
    effect: tool.effect,
    args: parsed.data,
  });
  if (!decision.allowed) {
    return { ok: false, error: 'forbidden', message: decision.reason };
  }

  if (tool.requiresConfirmation && !call.confirmationToken) {
    // Nao executa: devolve o pedido de confirmacao para o fluxo tratar.
    return { ok: false, error: 'confirmation_required', preview: decision.preview };
  }

  return tool.handler(parsed.data, {
    customerId: session.customerId,
    conversationId: session.conversationId,
    // Chave derivada da intencao: retry do agente nao duplica o efeito.
    idempotencyKey: session.conversationId + ':' + call.id,
  });
}

// Exemplo: o argumento NAO carrega customerId. O escopo e do servidor.
defineTool({
  name: 'buscar_pedido',
  effect: 'read',
  schema: z.object({ orderId: z.string().regex(/^[A-Z0-9-]{6,20}$/) }),
  handler: (args, ctx) => findOrder({ orderId: args.orderId, customerId: ctx.customerId }),
});

Repare que `buscar_pedido` não expõe um campo de cliente. O agente pode pedir qualquer identificador de pedido que quiser: a consulta é feita com o cliente da sessão no filtro, e um pedido de outra pessoa simplesmente não é encontrado. Isso é melhor que checar depois e recusar, porque não existe caminho em que o dado é lido antes da checagem, e porque a mensagem de erro não confirma ao curioso que aquele pedido existe.

04

Validar o argumento contra o esquema e depois contra a realidade

A validação de esquema resolve a forma: campo obrigatório presente, tipo certo, formato plausível, valor dentro de uma lista fechada quando faz sentido. É barata e barra uma classe inteira de erros, incluindo o modelo que inventa um campo ou manda uma string onde deveria ir um número. Mas ela é só a metade fácil. O esquema aceita `quantidade: 999999` e aceita uma data de entrega em 2031, porque ambos são números e datas válidos. O que barra esses é a segunda camada, a validação de domínio, que compara o argumento com o que faz sentido no seu negócio.

  • Prefira enum a string livre sempre que o conjunto for conhecido: motivo de cancelamento, tipo de ticket, canal de contato. Enum errado o modelo corrige no turno seguinte, string livre vira uma bifurcação que ninguém previu.
  • Coloque limites numéricos no esquema, não no handler: quantidade máxima, valor máximo, tamanho de página. O limite ausente é sempre descoberto por um caso extremo em produção.
  • Valide a coerência entre campos, não só cada campo isolado: data final depois da inicial, valor de reembolso menor ou igual ao valor pago, item pertencente ao pedido informado.
  • Trate a mensagem de erro como parte do contrato com o modelo: dizer qual campo falhou e por quê permite a correção no turno seguinte; um "argumentos inválidos" genérico produz três tentativas idênticas.
  • Não use a validação para esconder falta de autorização: recusar por esquema um recurso de outro cliente ainda revela que a diferença existe. Autorização é decisão à parte.

Há um caso que merece atenção especial, o argumento que vira consulta ou expressão. Toda ferramenta que aceita um trecho de linguagem gerado pelo modelo e o interpreta é, por definição, execução de código, mesmo que o nome sugira outra coisa. Uma ferramenta de relatório que recebe um filtro em texto e o concatena numa consulta é injeção esperando acontecer, com o agravante de que o atacante nem precisa falar com você: basta plantar o texto num documento que o RAG vai recuperar. Nesse caso o certo é não aceitar linguagem alguma: exponha parâmetros estruturados, monte a consulta você mesmo, e se a expressividade for realmente necessária, isole a execução como na próxima seção.

05

Isolar a execução: processo, rede, tempo e memória

Quando a ferramenta precisa mesmo rodar algo que você não escreveu, o controle deixa de ser lógico e passa a ser de recursos do sistema operacional. Rodar código gerado dentro do mesmo processo que atende suas conversas é a decisão que transforma qualquer erro em incidente: um laço infinito trava o serviço inteiro, uma alocação grande derruba o processo, um acesso à rede vira exfiltração e um acesso ao disco vira leitura das suas variáveis de ambiente. O isolamento precisa vir do ambiente de execução, não de uma lista de funções proibidas, porque a lista de proibidos é sempre incompleta.

// agent/sandbox.js
// Execucao isolada em processo filho: sem rede, sem heranca de ambiente,
// com teto de memoria e de tempo. O pai nunca fica esperando pra sempre.

import { spawn } from 'node:child_process';
import { once } from 'node:events';

const LIMITS = {
  timeoutMs: 2000,
  maxOldSpaceMb: 128,
  maxOutputBytes: 64 * 1024,
};

export async function runIsolated(source, input) {
  const child = spawn(
    process.execPath,
    [
      '--max-old-space-size=' + LIMITS.maxOldSpaceMb,
      // O runner nao importa nada: recebe o fonte pelo stdin e devolve
      // JSON pelo stdout. Sem rede e sem fs porque nada os fornece.
      new URL('./sandbox-runner.mjs', import.meta.url).pathname,
    ],
    {
      // Ambiente vazio: nenhuma credencial do processo pai vaza.
      env: {},
      cwd: '/tmp',
      stdio: ['pipe', 'pipe', 'pipe'],
      // Grupo proprio para o kill derrubar tambem eventuais netos.
      detached: true,
    },
  );

  const timer = setTimeout(() => {
    try {
      process.kill(-child.pid, 'SIGKILL');
    } catch {
      // processo ja morreu; nada a fazer
    }
  }, LIMITS.timeoutMs);

  let out = '';
  let truncated = false;
  child.stdout.on('data', (chunk) => {
    if (out.length + chunk.length > LIMITS.maxOutputBytes) {
      truncated = true;
      try {
        process.kill(-child.pid, 'SIGKILL');
      } catch {
        // ja morreu
      }
      return;
    }
    out += chunk;
  });

  child.stdin.end(JSON.stringify({ source, input }));

  const [code, signal] = await once(child, 'exit');
  clearTimeout(timer);

  if (signal === 'SIGKILL') {
    return { ok: false, error: truncated ? 'output_too_large' : 'timeout' };
  }
  if (code !== 0) {
    return { ok: false, error: 'execution_failed' };
  }
  try {
    return { ok: true, value: JSON.parse(out) };
  } catch {
    return { ok: false, error: 'invalid_output' };
  }
}

Três detalhes desse recorte costumam ser omitidos e são justamente os que importam. O ambiente vazio evita que a chave da API do provedor, que está numa variável do processo pai, seja legível pelo código executado. O grupo de processo próprio faz o kill alcançar netos, porque matar só o filho deixa órfão qualquer coisa que ele tenha lançado. E o teto de saída existe porque tempo e memória não cobrem o programa que imprime rápido demais: sem esse limite, o processo pai acumula a string até acabar a memória dele, e o isolamento protegeu o filho contra tudo menos contra derrubar o pai.

Um processo filho é o degrau intermediário, não o topo. Ele isola memória, tempo e ambiente, mas continua compartilhando o kernel e o sistema de arquivos da máquina. Se o código executado vier de fonte não confiável de verdade, o degrau seguinte é um contêiner com sistema de arquivos somente leitura, usuário sem privilégio, rede desligada e limites de CPU declarados, ou um runtime desenhado para isso, como uma máquina virtual de WebAssembly, onde o acesso ao mundo é uma lista explícita do que você concedeu em vez de uma lista do que você bloqueou.

06

Teto de consumo: a ferramenta que roda mil vezes

Nenhuma chamada individual precisa ser perigosa para o conjunto ser. Um agente em laço, tentando resolver uma tarefa impossível, chama a mesma ferramenta de busca quarenta vezes na mesma conversa; se ela consulta um parceiro que cobra por requisição, a fatura chega antes da percepção do bug. Por isso o teto de uso precisa ser por conversa, não só global: o limite global protege a infraestrutura contra o pico agregado, e é o limite por conversa que impede uma única conversa presa em laço de consumir o orçamento de todas as outras.

  1. Conte chamadas por ferramenta e por conversa, e recuse com erro tratável ao estourar, em vez de deixar o laço rodar até o timeout global.
  2. Some o custo real das ferramentas que custam dinheiro no mesmo orçamento da conversa que já contabiliza os tokens, senão a economia num lado é gasta no outro sem aparecer.
  3. Limite a profundidade do encadeamento: uma ferramenta que dispara outra que dispara outra precisa de um contador que atravesse a cadeia, ou a recursão só é descoberta pela conta.
  4. Trate estouro de teto como sinal, não só como bloqueio: uma conversa que bateu o limite de uma ferramenta é candidata a escalonamento humano, porque o agente evidentemente não está convergindo.
  5. Aplique o mesmo teto às retentativas: um retry automático dentro do handler multiplica o consumo real sem aparecer no contador que só conta chamadas do modelo.
  6. Registre a recusa por teto na trilha de auditoria com a ferramenta e a conversa, porque o padrão de quem bate limite é o melhor mapa de qual ferramenta está mal desenhada.

A resposta ao estourar o teto merece o mesmo cuidado da ferramenta desligada por kill switch: devolver ao modelo um erro explícito e tratável, dizendo que aquele recurso não está mais disponível nesta conversa, permite que ele siga com o que tem ou escale. Lançar uma exceção que derruba o turno transforma um limite de proteção numa falha visível ao cliente, e o cliente não fez nada de errado.

07

Testar a fuga: o teste que tenta escapar

Um sandbox sem teste adversarial é uma hipótese. O teste que importa não é o que confirma que a ferramenta funciona no caminho feliz, é o que tenta sair dela: pedir o recurso de outro cliente, mandar um argumento no limite do esquema, plantar uma instrução no documento recuperado, chamar a ferramenta irreversível sem confirmação, forçar o laço que estoura o teto. Cada um desses vira um caso de teste com uma asserção simples: o efeito não aconteceu e o erro voltou tratável.

// test/tool-sandbox.spec.js
// Testes que tentam escapar. A asserção e sempre dupla: nao houve efeito
// E o erro voltou tratavel, para o agente conseguir replanejar.

import { describe, it, expect, vi } from 'vitest';
import { runToolCall } from '../agent/tool-runtime.js';

const session = {
  customerId: 'cus_alice',
  conversationId: 'conv_1',
  allowedTools: new Set(['buscar_pedido', 'cancelar_assinatura']),
};

describe('sandbox de ferramentas', () => {
  it('nao le pedido de outro cliente mesmo com o id correto', async () => {
    // 'ord_bob_9' existe, mas pertence a outra pessoa.
    const res = await runToolCall(
      { id: 'c1', name: 'buscar_pedido', arguments: { orderId: 'ORD-BOB-9' } },
      session,
    );
    expect(res.ok).toBe(false);
    // Nao vaza a existencia do recurso: mesma resposta de "nao encontrado".
    expect(res.error).toBe('not_found');
  });

  it('ignora identidade injetada nos argumentos', async () => {
    const res = await runToolCall(
      {
        id: 'c2',
        name: 'buscar_pedido',
        // O modelo foi convencido a mandar um campo extra. O schema e
        // estrito, entao o campo desconhecido derruba a validacao.
        arguments: { orderId: 'ORD-ALICE-1', customerId: 'cus_bob' },
      },
      session,
    );
    expect(res.ok).toBe(false);
    expect(res.error).toBe('invalid_arguments');
  });

  it('nao executa acao irreversivel sem confirmacao', async () => {
    const cancel = vi.fn();
    const res = await runToolCall(
      { id: 'c3', name: 'cancelar_assinatura', arguments: { reason: 'price' } },
      session,
    );
    expect(cancel).not.toHaveBeenCalled();
    expect(res.error).toBe('confirmation_required');
  });

  it('recusa ferramenta fora do conjunto permitido', async () => {
    const res = await runToolCall(
      { id: 'c4', name: 'emitir_reembolso', arguments: { amount: 100 } },
      session,
    );
    expect(res.ok).toBe(false);
    expect(res.error).toBe('tool_not_allowed');
  });

  it('mata o codigo isolado que entra em laco infinito', async () => {
    const started = process.hrtime.bigint();
    const res = await runIsolated('while (true) {}', {});
    const elapsedMs = Number(process.hrtime.bigint() - started) / 1e6;
    expect(res.ok).toBe(false);
    expect(res.error).toBe('timeout');
    // O teto e 2s: falhar aqui significa que o kill nao alcancou o processo.
    expect(elapsedMs).toBeLessThan(3000);
  });
});

Esses testes têm uma propriedade que os torna mais valiosos que a maioria: eles não dependem do modelo. Como a defesa é código determinístico, o teste chama a camada de execução diretamente com o pedido hostil, sem gastar uma chamada ao provedor e sem a variabilidade que tornaria o resultado intermitente. Isso permite rodá-los no CI a cada commit, que é onde eles precisam estar, porque a regressão típica não é alguém apagar a checagem: é alguém adicionar uma ferramenta nova e esquecer de declarar a classe de efeito.

FAQ

Perguntas frequentes

Por que não basta instruir no prompt que o agente não pode executar certas ações?

Porque a instrução no prompt só altera a probabilidade de o modelo pedir a ação, e não impede que a ação aconteça. Entre a decisão do modelo e o efeito no mundo existe um trecho de código seu, e é ele que executa: se esse código não valida nada, o pedido vira efeito independentemente do que o prompt dizia. Além disso, no agente a entrada hostil nem precisa vir do cliente. Ela chega pelo trecho recuperado do RAG, pelo corpo de um e-mail que o agente foi ler ou pelo retorno de uma ferramenta anterior, e tudo isso entra no contexto com o mesmo status de texto da instrução original, sem um canal que separe dado de ordem. A defesa não pode morar no mesmo lugar que o ataque: ela mora na camada de execução, que é determinística e não muda de comportamento porque um documento pediu com educação.

Como impedir que o agente acesse dados de outro cliente através de uma ferramenta de leitura?

Tirando a identidade do argumento. O identificador de quem está pedindo nunca deve ser um campo que o modelo preenche, porque ele preenche a partir do contexto e o contexto inclui tudo que o cliente escreveu: basta pedir "consulte o pedido 8842" para o agente consultar o pedido 8842. O correto é que a identidade venha do estado da conversa, estabelecido na autenticação, e que a consulta seja montada no servidor com esse cliente no filtro, de modo que um recurso de outra pessoa simplesmente não seja encontrado. Isso é melhor que checar depois e recusar, porque não existe caminho em que o dado é lido antes da checagem e porque a resposta não confirma que aquele recurso existe. Vale lembrar que as credenciais que o executor usa contra os sistemas internos costumam ser de serviço, com permissão ampla, e é exatamente por isso que a checagem por cliente precisa acontecer antes, no seu código.

Quando um processo isolado não é suficiente para rodar código gerado pelo modelo?

Um processo filho com ambiente vazio, teto de memória, timeout e limite de saída resolve a maior parte dos casos internos: ele impede que um laço infinito trave o serviço, que uma alocação grande derrube o processo principal e que as variáveis de ambiente do pai, incluindo chaves de API, sejam legíveis pelo código executado. O que ele não faz é isolar o kernel e o sistema de arquivos da máquina, que continuam compartilhados. Se o código executado vem de fonte não confiável de verdade, ou se ele manipula dados de vários clientes, o degrau seguinte é um contêiner com sistema de arquivos somente leitura, usuário sem privilégio, rede desligada e limites de CPU declarados, ou um runtime desenhado para isolamento como uma máquina virtual de WebAssembly, onde o acesso ao mundo é uma lista explícita do que foi concedido em vez de uma lista do que foi bloqueado.

O limite do agente é o que o código permite, não o que o prompt pede

Todo agente com tool use tem uma fronteira entre o que o modelo pede e o que acontece de verdade, e essa fronteira é código seu. Classificar cada ferramenta pelo efeito define quanto controle ela merece; tirar a identidade dos argumentos e reavaliar a autorização no ponto de execução fecha a leitura de dado alheio; validar contra o esquema e depois contra o domínio barra o argumento absurdo antes do handler; isolar em processo com ambiente vazio, teto de tempo, memória e saída impede que código gerado derrube o serviço; e o teto de consumo por conversa evita que um laço do agente pague a conta de todo mundo. Nada disso depende de o modelo se comportar, que é exatamente o ponto. Posso desenhar e implementar essa camada de execução no seu agente, do registro de ferramentas com classe de efeito aos testes adversariais no CI, para que a ação perigosa seja impossível em vez de improvável.