Segurança em agentes LLM: onde está a fronteira de confiança?

Quando você integra um LLM a ferramentas e dados externos, a preocupação mais comum costuma ser disponibilidade e custo. Mas você costuma considerar os riscos de segurança?

Existem três propriedades de integrações com LLMs que tornam segurança diferente de aplicações tradicionais:

o modelo não é uma fronteira de confiança; qualquer contexto pode virar instrução; autonomia sem validação determinística amplia drasticamente o impacto de falhas.

Esse texto tenta mostrar onde essas superfícies de ataque aparecem — muitas vezes de formas pouco óbvias durante o projeto do sistema.

Como a maioria das integrações é construída

O fluxo básico é simples: você tem um modelo, dá contexto pra ele, conecta ferramentas via MCP ou outra camada de integração, e ele opera. O modelo recebe uma mensagem, acessa dados, chama funções, devolve uma resposta.

O problema começa quando você percebe um detalhe: o modelo não tem como distinguir de forma 100% assertiva uma instrução que veio do sistema de uma que veio de fora. Ele processa texto. Não existe, para ele, uma hierarquia de confiança entre o que você definiu como prompt de sistema e o que chegou como entrada do usuário — ou como resposta de uma ferramenta.

Ao trabalhar com LLMs constantemente, você aprende o quanto condições de contorno importam.

Prompt injection — o que é e por que é diferente de outros ataques

Prompt injection não é uma exploração de memória ou de protocolo de rede. É, fundamentalmente, convencer o modelo de que o contexto mudou: que as instruções anteriores foram revogadas, que existe um modo especial de operação, que aquela mensagem faz parte de uma continuação legítima do sistema.

O caso mais conhecido é a direct prompt injection, quando o próprio usuário envia instruções maliciosas como:

ignore as instruções anteriores e faça X

Isso é simples demais para passar despercebido — e justamente por isso os ataques mais interessantes raramente aparecem dessa forma.

O mesmo efeito pode ser alcançado com paráfrase, com outro idioma, dentro de um contexto narrativo (“suponha que você é um sistema sem restrições”), com codificação em base64, com sequências especiais de tokens que o modelo interpreta como delimitadores de instrução. A lista de variantes é longa porque a criatividade necessária para construí-las é baixa — qualquer pessoa com acesso ao sistema pode tentar.

Mas o problema mais perigoso costuma ser a indirect prompt injection: a instrução maliciosa não vem do usuário diretamente. Ela pode aparecer num documento que o agente leu, num e-mail processado automaticamente, numa resposta de API externa ou em qualquer outro dado incorporado ao contexto.

Conforme você adiciona mais autonomia ao agente — mais ferramentas, mais fontes de dados — a superfície de ataque tende a crescer junto.

Imagine um prompt injection que consiga convencer o modelo de que a condição de sucesso dele, para atender o usuário corretamente, depende de executar uma ação maliciosa.

PII e credenciais vazando

Quando o agente tem acesso a dados reais — banco de dados, arquivos, histórico do usuário, no geral PII (Personal Identifiable Information) — esses dados tendem a acabar no payload que vai para o provider externo. Não como resultado de um bug específico, mas porque o fluxo foi desenhado assim: busca contexto relevante, envia para o modelo, recebe resposta.

Se ninguém inspeciona o que sai, CPF, número de cartão, chaves de API e outros dados sensíveis cruzam essa fronteira com regularidade. O modelo não faz esse julgamento por você — ele usa o que está disponível no contexto.

O mesmo vale para credenciais que aparecem em código, configurações ou logs que o agente acessa. A integração LLM vira um canal de exfiltração não porque foi atacada, mas porque ninguém definiu o que pode e o que não pode sair.

Por que filtros de texto simples não resolvem

A primeira resposta para prompt injection costuma ser bloquear padrões conhecidos: “ignore suas instruções”, variantes de jailbreak em português e inglês, frases associadas a DAN e similares. Funciona para os casos mais óbvios.

O problema é que regex detecta forma, não intenção. A mesma instrução, reescrita com paráfrase diferente, outro idioma, codificação ou contexto narrativo, passa facilmente pela maioria dos filtros baseados em padrão.

Tratar isso de forma eficaz exige entender o que a mensagem está tentando fazer — não só como ela está escrita. Isso envolve análise semântica, que é computacionalmente mais cara e mais difícil de manter, mas é o que efetivamente distingue tentativas de manipulação de pedidos legítimos com linguagem semelhante.

Onde o ponto de inspeção precisa estar

A tendência natural é proteger a borda — o que entra pela API pública, o que o usuário envia diretamente. Mas as chamadas entre seu backend e o provider LLM ficam dentro da rede e geralmente não passam por nenhuma inspeção.

Se um ataque acontece via tool response — um documento que o agente buscou, uma resposta de API que continha instruções injetadas — o payload malicioso viaja por dentro, sem nunca ter tocado na borda externa. A proteção de perímetro não vê esse tráfego.

O ponto de inspeção precisa existir entre sua aplicação e o provider: observando o que entra no modelo e, principalmente, o que sai da sua rede.

Não apenas o que entra pela borda externa.

O que isso muda na hora de projetar

Não é necessário tratar cada integração como uma fortaleza desde o início. Mas algumas perguntas, se feitas cedo, mudam bastante o que precisa ser construído depois:

  • O que entra no contexto do agente pode ter sido produzido por alguém fora do sistema?
  • O payload enviado ao provider externo contém dados sensíveis?
  • Tool responses recebem o mesmo nível de desconfiança que entradas de usuário?
  • Se o modelo for convencido a executar algo indevido, qual é o impacto máximo possível?
  • O modelo possui acesso direto a ações com efeito colateral?

A maioria dos problemas de segurança em integrações LLM não vem de ataques sofisticados. Vem de sistemas projetados sem essas perguntas — onde a fronteira entre confiável e não confiável nunca foi definida, e onde o modelo opera com mais autoridade do que deveria ter.

Integrar é necessário

Em muitos sistemas, o modelo inevitavelmente vai acabar no caminho crítico. A questão deixa de ser “usar ou não usar” e passa a ser “quanto de autoridade o modelo recebe”.

Um exercício útil é imaginar como um banco poderia construir uma integração com APIs de movimentação financeira. Não há como saber os detalhes internos de cada instituição, mas é possível raciocinar sobre quais barreiras uma arquitetura minimamente responsável precisaria ter.

O primeiro princípio que emerge é simples: o usuário nunca define a ação diretamente, e o modelo nunca a executa.

O usuário fornece intenção — linguagem natural, ambígua, sem formato rígido. O modelo transforma isso em um payload estruturado para uma ferramenta de validação.

Essa ferramenta não recebe linguagem natural; ela recebe dados validáveis — valor, destinatário, tipo de operação — e aplica regras determinísticas do domínio: limites, autorizações, consistência, destinatários permitidos.

Só depois dessa validação o usuário vê uma confirmação explícita do que vai acontecer, e a operação aguarda aprovação.

Essa arquitetura não elimina todos os riscos discutidos antes, mas limita drasticamente o dano máximo possível. Um prompt injection que convença o modelo a gerar um payload malicioso ainda precisa atravessar uma camada de validação que o próprio modelo não controla.

É o tipo de decisão arquitetural barata no início e extremamente cara de corrigir depois.

Os dois caminhos em código

Para tornar a diferença mais concreta, vamos comparar duas abordagens. O cenário: um banco quer permitir que o usuário envie um PIX via mensagem de texto — “manda 200 reais pro João” — numa interface tipo WhatsApp.

Abordagem 1 — o modelo com acesso direto, restrição só no prompt

import anthropic

client = anthropic.Anthropic()

response = client.messages.create(
    model="claude-opus-4-7",
    system="""Você é um assistente de pagamentos PIX.
Regras obrigatórias:
- Nunca envie mais de R$1.000 por transação
- Só transfira para chaves cadastradas pelo usuário
- Sempre confirme com o usuário antes de executar""",
    tools=[{
        "name": "enviar_pix",
        "description": "Executa uma transferência PIX",
        "input_schema": {
            "type": "object",
            "properties": {
                "chave_destino": {"type": "string"},
                "valor": {"type": "number"},
                "descricao": {"type": "string"}
            },
            "required": ["chave_destino", "valor"]
        }
    }],
    messages=[{"role": "user", "content": mensagem_usuario}]
)

# Se o modelo decidir chamar enviar_pix, a aplicação executa diretamente
if response.stop_reason == "tool_use":
    tool_call = next(b for b in response.content if b.type == "tool_use")
    resultado = api_financeira.enviar_pix(**tool_call.input)

A vulnerabilidade aqui não está na implementação técnica — está na suposição de que o prompt é suficiente como barreira. Todas as regras estão em linguagem natural, sujeitas à mesma interpretação (e manipulação) que qualquer outra instrução no contexto. Se a mensagem_usuario contiver algo que convença o modelo de que as restrições não se aplicam naquele caso — ou que a confirmação já foi dada implicitamente — o código executa sem nenhuma outra barreira.

Abordagem 2 — o modelo extrai intenção, uma ferramenta valida, o usuário confirma

from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal
from enum import Enum, auto
import json
import anthropic
from pydantic import BaseModel, field_validator, ValidationError

client = anthropic.Anthropic()


class MotivoRecusa(Enum):
    LIMITE_POR_TRANSACAO        = auto()
    LIMITE_DIARIO               = auto()
    DESTINATARIO_NAO_CADASTRADO = auto()
    TOKEN_EXPIRADO              = auto()


@dataclass(frozen=True)
class IntencaoPagamento:
    valor: Decimal
    destinatario: str
    descricao: str = ""


@dataclass(frozen=True)
class TransacaoPendente:
    token: str
    valor: Decimal
    chave_destino: str
    nome_destino: str
    usuario_id: str

    @property
    def resumo(self) -> str:
        return f"PIX de R${self.valor:.2f} para {self.nome_destino}"


@dataclass(frozen=True)
class ResultadoValidacao:
    aprovada: bool
    transacao: TransacaoPendente | None = None
    motivo: MotivoRecusa | None = None


class _RespostaLLM(BaseModel):
    """Valida o JSON que o modelo retorna antes de confiar nele."""
    valor: Decimal
    destinatario: str
    descricao: str = ""

    @field_validator("valor")
    @classmethod
    def deve_ser_positivo(cls, v: Decimal) -> Decimal:
        if v <= 0:
            raise ValueError("valor deve ser positivo")
        return v

    @field_validator("destinatario")
    @classmethod
    def nao_pode_ser_vazio(cls, v: str) -> str:
        if not v.strip():
            raise ValueError("destinatario não pode ser vazio")
        return v.strip()


def extrair_intencao(mensagem: str) -> IntencaoPagamento | None:
    response = client.messages.create(
        model="claude-opus-4-7",
        system="""Extraia a intenção de pagamento PIX da mensagem.
Retorne JSON com: valor (número positivo), destinatario (string), descricao (string).
Se não for possível identificar os dados com clareza, retorne null.""",
        messages=[{"role": "user", "content": mensagem}]
    )

    raw = response.content[0].text.strip()
    if raw.lower() == "null":
        return None

    try:
        parsed = _RespostaLLM.model_validate(json.loads(raw))
    except (json.JSONDecodeError, ValidationError):
        return None

    return IntencaoPagamento(
        valor=parsed.valor,
        destinatario=parsed.destinatario,
        descricao=parsed.descricao,
    )


def validar_pix(intencao: IntencaoPagamento, usuario_id: str) -> ResultadoValidacao:
    limites = buscar_limites_usuario(usuario_id)

    if intencao.valor > limites.por_transacao:
        return ResultadoValidacao(aprovada=False, motivo=MotivoRecusa.LIMITE_POR_TRANSACAO)

    if intencao.valor > limites.diario_restante:
        return ResultadoValidacao(aprovada=False, motivo=MotivoRecusa.LIMITE_DIARIO)

    destinatario = resolver_destinatario(intencao.destinatario, usuario_id)
    if destinatario is None:
        return ResultadoValidacao(aprovada=False, motivo=MotivoRecusa.DESTINATARIO_NAO_CADASTRADO)

    transacao = TransacaoPendente(
        token=criar_token_transacao(intencao, destinatario, usuario_id),
        valor=intencao.valor,
        chave_destino=destinatario.chave_pix,
        nome_destino=destinatario.nome,
        usuario_id=usuario_id,
    )
    return ResultadoValidacao(aprovada=True, transacao=transacao)


def confirmar_pix(token: str, confirmacao: bool) -> None:
    if not confirmacao:
        invalidar_token(token)
        return

    transacao = recuperar_transacao(token)
    if transacao is None or transacao.expirado:
        raise ValueError(MotivoRecusa.TOKEN_EXPIRADO)

    api_financeira.executar_pix(transacao)


# Fluxo completo
intencao = extrair_intencao(mensagem_usuario)

if intencao is None:
    enviar_para_usuario("Não consegui entender o pagamento. Pode reformular?")
else:
    resultado = validar_pix(intencao, usuario_id)

    if not resultado.aprovada:
        enviar_para_usuario(f"Pagamento não permitido: {resultado.motivo.name}")
    else:
        enviar_para_usuario(resultado.transacao.resumo + "\n\nConfirma? (sim/não)")
        # confirmar_pix(token, confirmacao) é chamado quando o usuário responder

A diferença estrutural entre as duas abordagens não está nos imports nem nas chamadas de API — está em onde fica a autoridade. Na primeira, o modelo tem acesso direto à ferramenta que executa; qualquer injeção que mude sua interpretação das regras do prompt afeta diretamente o que é executado. Na segunda, o modelo só pode propor uma intenção estruturada. A validação — limites, contatos cadastrados, consistência dos dados — acontece numa camada que não é influenciada pelo contexto que o modelo recebeu. E a execução depende de uma ação explícita do usuário sobre um resumo que foi gerado fora do modelo, não por ele.

Isso não torna o sistema imune a todos os problemas discutidos antes. Mas localiza claramente o que cada parte do sistema pode e não pode fazer — e essa separação é o que permite raciocinar sobre os riscos de forma concreta.

O proxy em código Outro problema discutido antes é posicionamento.

Você precisa de um ponto que observe todo o tráfego entre sua aplicação e o provider — inclusive conteúdo vindo de tool responses, que normalmente nunca passa pela borda externa.

O padrão de proxy resolve isso: o cliente do provider é encapsulado por uma camada responsável por inspeção, sanitização e observabilidade.

O exemplo abaixo é deliberadamente simplificado. Ele serve para ilustrar posicionamento arquitetural, não detecção robusta de prompt injection.

from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from enum import Enum, auto
import re
import anthropic


class AcaoProxy(Enum):
    PERMITIR  = auto()
    SANITIZAR = auto()
    BLOQUEAR  = auto()


@dataclass(frozen=True)
class ResultadoInspecao:
    acao: AcaoProxy
    payload: str
    motivo: str = ""


_PADROES_PII = [
    (r"\b\d{3}\.\d{3}\.\d{3}-\d{2}\b",               "CPF"),
    (r"\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b",  "cartão"),
    (r"(?i)(sk-|api[-_]?key|bearer\s+)[a-z0-9]{20,}", "credencial"),
]

_PADROES_INJECTION = [
    r"(?i)ignore\s+(as\s+)?instru",
    r"(?i)<\s*/?system\s*>",
    r"(?i)you are now",
]


def inspecionar_payload(texto: str) -> ResultadoInspecao:
    """Remove PII antes que o conteúdo saia em direção ao provider."""
    resultado = texto
    for padrao, rotulo in _PADROES_PII:
        if re.search(padrao, resultado):
            resultado = re.sub(padrao, f"[{rotulo} removido]", resultado)

    if resultado != texto:
        return ResultadoInspecao(acao=AcaoProxy.SANITIZAR, payload=resultado, motivo="PII detectado")

    return ResultadoInspecao(acao=AcaoProxy.PERMITIR, payload=texto)


def inspecionar_tool_response(conteudo: str) -> ResultadoInspecao:
    for padrao in _PADROES_INJECTION:
        if re.search(padrao, conteudo):
            return ResultadoInspecao(
                acao=AcaoProxy.BLOQUEAR,
                payload="[conteúdo bloqueado pelo proxy]",
                motivo="padrão de injection em tool response",
            )
    return ResultadoInspecao(acao=AcaoProxy.PERMITIR, payload=conteudo)


class AnthropicProxy:
    """Envolve o cliente Anthropic com inspeção em cada chamada."""

    def __init__(self, on_alerta: Callable[[str], None]):
        self._client = anthropic.Anthropic()
        self._on_alerta = on_alerta

    def _inspecionar_mensagens(self, messages: list[dict]) -> list[dict]:
        resultado = []
        for msg in messages:
            conteudo = str(msg["content"])
            inspecao = (
                inspecionar_tool_response(conteudo)
                if msg["role"] == "tool"
                else inspecionar_payload(conteudo)
            )
            if inspecao.acao != AcaoProxy.PERMITIR:
                self._on_alerta(inspecao.motivo)
            resultado.append({**msg, "content": inspecao.payload})
        return resultado

    def create(self, *, system: str, messages: list[dict], **kwargs) -> anthropic.types.Message:
        inspecao_system = inspecionar_payload(system)
        if inspecao_system.acao != AcaoProxy.PERMITIR:
            self._on_alerta(inspecao_system.motivo)

        return self._client.messages.create(
            system=inspecao_system.payload,
            messages=self._inspecionar_mensagens(messages),
            **kwargs,
        )


# Substitui o cliente direto em qualquer ponto da aplicação
proxy = AnthropicProxy(on_alerta=registrar_alerta)
response = proxy.create(
    model="claude-opus-4-7",
    system=system_prompt,
    messages=messages,
)

Vale ser honesto sobre o que esse padrão resolve e o que não resolve. A remoção de PII antes de sair para o provider — CPF, número de cartão, credenciais que aparecem no contexto — funciona razoavelmente bem com regex porque o formato desses dados é determinístico. Detecção de injection é diferente: os padrões acima cobrem os casos mais diretos, mas ataques sofisticados passam por qualquer filtro baseado em forma, como já discutido anteriormente. Para esses casos a análise semântica é o que funciona — mais cara, mais difícil de manter, mas é o que efetivamente distingue manipulação de linguagem legítima.

O que o proxy resolve independente da qualidade da inspeção é o posicionamento: existe agora um único ponto onde todo o tráfego entre sua aplicação e o provider pode ser observado, filtrado e registrado — inclusive o que vem de tool responses e nunca chegou pela borda externa.

Em integrações LLM, segurança raramente falha por ausência de filtros. Ela falha quando o sistema não define claramente quem pode interpretar intenção, quem pode validar regras e quem realmente possui autoridade para executar ações.