# Como mascarar CPF e dados pessoais antes de enviar ao Claude no Laravel (LGPD)

> Detecção com validação de dígito, marcadores reversíveis por conversa e restauração só no servidor: como tirar CPF, e-mail e chave Pix do prompt sem quebrar o agente de IA.

Por Lucas Roberto · IA · publicado em 2026-10-29 · 10 min de leitura
Versão web: https://lucasroberto.com/blog/como-mascarar-cpf-e-dados-pessoais-antes-de-enviar-ao-claude-no-laravel-lgpd

Para não mandar CPF, e-mail, telefone e chave Pix ao Claude, troque cada dado pessoal por um marcador como `[CPF_1]` antes da chamada e guarde o valor real num cofre da conversa, do lado do servidor. O modelo raciocina sobre os marcadores. O Laravel restaura os valores reais em dois pontos: na resposta final ao usuário e nos argumentos das tool calls. A detecção precisa validar dígito verificador e Luhn, senão você mascara número de pedido e deixa passar CPF formatado de um jeito diferente.

É o padrão que eu uso quando um LLM entra em fluxo de fintech: atendimento no WhatsApp, leitura de extrato, conciliação. Abaixo estão as decisões, o código e os pontos onde a abordagem quebra.

## Por que mascarar dados pessoais antes de chamar a API do LLM?

Três motivos práticos, nenhum deles teórico.

1. **Necessidade (LGPD, art. 6º, III).** O princípio da necessidade limita o tratamento ao mínimo exigido para a finalidade. Para categorizar uma transação ou responder "meu Pix caiu?", o modelo não precisa do CPF do cliente. Precisa saber que existe *um* CPF e que ele é o mesmo em dois lugares.
2. **Transferência para fora.** Mandar o prompt para a Anthropic ou para a OpenAI é compartilhar dado com um operador que processa fora do Brasil. Dá para fazer de forma legal, com contrato e base adequada, mas cada dado pessoal que você deixa de enviar é um dado a menos nessa conversa com o jurídico. Leia os termos e o DPA do provedor. Não presuma nada sobre retenção.
3. **Vazamento lateral.** O prompt não fica só na API. Ele vai para os seus logs, para o trace do agente, para o resumo de memória, para o dataset de avaliação. Se o texto entra mascarado, todas essas cópias já nascem limpas.

Um detalhe de vocabulário importa aqui. **Anonimização** é tornar o dado irreversivelmente não associável a uma pessoa. **Pseudonimização** é trocar o identificador por outro, mantendo a chave de reversão separada e protegida. O que vou mostrar é pseudonimização. Os dados continuam pessoais para a LGPD, mas a exposição cai bastante.

## Quais dados mascarar e como detectar cada um?

Em fintech, a lista que cobre a maioria dos casos é esta:

| Dado | Detecção | Ação |
|---|---|---|
| CPF | regex + dígito verificador | marcador reversível `[CPF_n]` |
| CNPJ | regex + dígito verificador | reversível (ou manter, se for PJ e a finalidade pedir) |
| E-mail | regex | reversível `[EMAIL_n]` |
| Telefone BR | regex com DDD | reversível `[TELEFONE_n]` |
| Chave Pix aleatória (EVP) | regex de UUID | reversível `[CHAVE_PIX_n]` |
| Número de cartão (PAN) | regex + Luhn | **irreversível** `[CARTAO]` |
| Nome do cliente | entidade conhecida no contexto | reversível `[NOME_n]` |
| Valor, data, descrição | — | manter, é o que o modelo precisa |

O cartão é irreversível de propósito. PAN (o número completo do cartão) não deveria estar trafegando no seu back-end fora do escopo PCI, e muito menos indo para um LLM. Se apareceu numa mensagem de WhatsApp, apague e pronto. Não existe motivo para o modelo devolver esse número.

Nome é o caso difícil. Regex não acha nome, e modelos de NER (reconhecimento de entidades nomeadas) rodando localmente custam infraestrutura e erram em português informal. Na prática, o que funciona é mascarar os nomes que você **já conhece**: o titular da conta, os favorecidos cadastrados, o nome do estabelecimento. Um nome desconhecido digitado pelo usuário pode passar, e esse é um trade-off que vale registrar por escrito.

## Como gerar marcadores consistentes e reversíveis?

O marcador precisa ser **estável dentro da conversa**. Se o mesmo CPF aparece formatado numa mensagem (`123.456.789-09`) e cru em outra (`12345678909`), os dois têm que virar `[CPF_1]`. É isso que permite ao modelo perceber que o pagador do comprovante é o próprio cliente. Por isso o cofre normaliza o valor antes de indexar.

```php
final class PiiVault
{
    private array $byValue = [];
    private array $byToken = [];
    private array $counters = [];

    public function tokenize(string $type, string $normalized): string
    {
        $key = $type.':'.$normalized;

        if (isset($this->byValue[$key])) {
            return $this->byValue[$key];
        }

        $n = $this->counters[$type] = ($this->counters[$type] ?? 0) + 1;
        $token = "[{$type}_{$n}]";

        $this->byValue[$key] = $token;
        $this->byToken[$token] = $normalized;

        return $token;
    }

    public function resolve(string $token): ?string
    {
        return $this->byToken[$token] ?? null;
    }

    public function restore(string $text): string
    {
        // Tolera o modelo escrever CPF_1 sem colchetes
        return preg_replace_callback(
            '/\[?\b([A-Z_]+_\d+)\b\]?/',
            fn ($m) => $this->resolve('['.$m[1].']') ?? $m[0],
            $text,
        );
    }

    public function toArray(): array
    {
        return [$this->byValue, $this->byToken, $this->counters];
    }

    public static function fromArray(array $data): self
    {
        $vault = new self();
        [$vault->byValue, $vault->byToken, $vault->counters] = $data;

        return $vault;
    }
}
```

O `restore` só troca marcadores que existem no cofre. Se o modelo inventar `[CPF_7]` numa conversa que só tem `[CPF_1]`, o texto sai como está, e isso é um sinal útil para o seu monitoramento.

## Como validar CPF e cartão para não mascarar coisa errada?

Regex sozinha gera falso positivo. Onze dígitos seguidos podem ser um CPF, um telefone com DDD, um número de pedido ou um NSU de maquininha. Validar o dígito verificador elimina a maior parte do ruído.

```php
final class Cpf
{
    public static function isValid(string $d): bool
    {
        if (strlen($d) !== 11 || preg_match('/^(\d)\1{10}$/', $d)) {
            return false;
        }

        for ($t = 9; $t < 11; $t++) {
            $sum = 0;
            for ($i = 0; $i < $t; $i++) {
                $sum += (int) $d[$i] * (($t + 1) - $i);
            }
            if ((int) $d[$t] !== ((10 * $sum) % 11) % 10) {
                return false;
            }
        }

        return true;
    }
}

function luhn(string $d): bool
{
    $sum = 0;
    $double = false;

    for ($i = strlen($d) - 1; $i >= 0; $i--) {
        $n = (int) $d[$i];
        if ($double && ($n *= 2) > 9) {
            $n -= 9;
        }
        $sum += $n;
        $double = ! $double;
    }

    return $sum % 10 === 0;
}
```

O redator aplica os detectores numa ordem definida. Primeiro os mais longos e específicos (cartão, UUID, e-mail), depois CPF e telefone. Assim um padrão curto não come pedaço de um longo.

```php
final class PiiRedactor
{
    public function redact(string $text, PiiVault $vault, array $knownNames = []): string
    {
        $text = preg_replace_callback('/\b(?:\d[ -]?){13,19}\b/', function ($m) {
            $digits = preg_replace('/\D/', '', $m[0]);
            return luhn($digits) ? '[CARTAO]' : $m[0];
        }, $text);

        $text = preg_replace_callback(
            '/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i',
            fn ($m) => $vault->tokenize('CHAVE_PIX', strtolower($m[0])),
            $text,
        );

        $text = preg_replace_callback(
            '/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/',
            fn ($m) => $vault->tokenize('EMAIL', mb_strtolower($m[0])),
            $text,
        );

        $text = preg_replace_callback('/\b\d{3}\.?\d{3}\.?\d{3}-?\d{2}\b/', function ($m) use ($vault) {
            $digits = preg_replace('/\D/', '', $m[0]);
            return Cpf::isValid($digits) ? $vault->tokenize('CPF', $digits) : $m[0];
        }, $text);

        $text = preg_replace_callback(
            '/(?:\+?55\s?)?\(?\b\d{2}\)?\s?9?\d{4}-?\d{4}\b/',
            fn ($m) => $vault->tokenize('TELEFONE', preg_replace('/\D/', '', $m[0])),
            $text,
        );

        foreach ($knownNames as $name) {
            $pattern = '/\b'.preg_quote($name, '/').'\b/iu';
            $text = preg_replace($pattern, $vault->tokenize('NOME', mb_strtolower($name)), $text);
        }

        return $text;
    }
}
```

Dois avisos sobre esse código. A regex de telefone é propositalmente gulosa: prefiro mascarar um número de pedido que parece telefone a deixar passar um telefone real. Já um CPF válido que na verdade é um celular de 11 dígitos vira `[CPF_n]`. O tipo sai errado, mas o dado não vaza, e o modelo raramente depende do tipo para responder. Ajuste a agressividade conforme o domínio. Em conciliação, onde NSU e número de autorização importam, eu restrinjo a regex de telefone a textos livres e não a aplico em campos estruturados.

## Como fazer o agente usar o dado real nas tool calls sem o modelo ver o dado?

Esse é o ponto que costuma travar quem tenta mascarar um agente de IA em vez de um prompt isolado. O cliente pergunta "tem algum Pix pendente no meu CPF?". O modelo precisa chamar `buscar_pix_pendentes(cpf: ...)`. Com o mascaramento, ele vai chamar com `"[CPF_1]"`, e isso é exatamente o que você quer.

A regra é: **o executor de ferramentas resolve os marcadores, o modelo nunca vê o valor**. Antes de executar, você percorre o `input` da tool call e troca marcadores pelos valores do cofre. Depois de executar, você mascara o resultado antes de devolvê-lo como `tool_result`.

```php
foreach ($response['content'] as $block) {
    if ($block['type'] !== 'tool_use') {
        continue;
    }

    $input = $this->restoreDeep($block['input'], $vault);
    $result = $this->tools->run($block['name'], $input, $conversation);

    $toolResults[] = [
        'type' => 'tool_result',
        'tool_use_id' => $block['id'],
        'content' => $this->redactor->redact(
            json_encode($result, JSON_UNESCAPED_UNICODE),
            $vault,
            $conversation->knownNames(),
        ),
    ];
}

private function restoreDeep(mixed $value, PiiVault $vault): mixed
{
    return match (true) {
        is_array($value) => array_map(fn ($v) => $this->restoreDeep($v, $vault), $value),
        is_string($value) => $vault->restore($value),
        default => $value,
    };
}
```

Isso traz um benefício de segurança que eu não esperava quando comecei. O cofre só contém os dados que **o próprio usuário daquela conversa** trouxe ou que suas ferramentas devolveram para ele. Se um prompt injection convencer o modelo a chamar a ferramenta com outro CPF, ele teria que digitar o CPF real, e você pode recusar argumentos de identificação que não venham como marcador do cofre. A autorização da ferramenta continua sendo a defesa principal, mas é uma camada extra barata.

O mascaramento do `tool_result` também entra na conta do seu tratamento de erro. Se a ferramenta falhar, a mensagem de erro passa pelo redator. Exceção de banco adora ecoar o valor que causou o problema. Escrevi sobre o resto desse fluxo em [erros no loop do agente de IA com is_error e stop_reason](/blog/erros-no-loop-do-agente-de-ia-no-laravel-is-error-stop-reason-e-limite-de-iteracoes).

## Onde guardar o cofre de marcadores entre mensagens?

No WhatsApp a conversa atravessa várias requisições e vários jobs. O cofre precisa durar a conversa inteira, senão `[CPF_1]` de ontem vira outro CPF hoje. Uso o cache do Laravel no Redis, criptografado e com TTL alinhado à janela da conversa:

```php
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Crypt;

function loadVault(string $conversationId): PiiVault
{
    $payload = Cache::get("pii-vault:{$conversationId}");

    return $payload
        ? PiiVault::fromArray(json_decode(Crypt::decryptString($payload), true))
        : new PiiVault();
}

function saveVault(string $conversationId, PiiVault $vault): void
{
    Cache::put(
        "pii-vault:{$conversationId}",
        Crypt::encryptString(json_encode($vault->toArray())),
        now()->addHours(24),
    );
}
```

Criptografar com `Crypt` evita que um `redis-cli MONITOR` ou um dump de memória exponha o mapeamento em texto claro. Se a conversa precisa durar mais que o TTL, persista o cofre numa coluna com cast `encrypted` no PostgreSQL. A mesma lógica que descrevi em [criptografar CPF no Laravel com encrypted cast e blind index](/blog/como-criptografar-cpf-no-laravel-sem-perder-a-busca-encrypted-cast-e-blind-index) vale aqui.

Um cuidado com concorrência. Se dois jobs processam mensagens da mesma conversa em paralelo, cada um carrega o cofre, cria `[CPF_2]` para valores diferentes e o último a salvar vence. Serialize o processamento por conversa com `Cache::lock` ou com `WithoutOverlapping` usando o ID da conversa como chave. Você provavelmente já faz isso para manter a ordem das mensagens.

## O que mudar no system prompt e no histórico?

O modelo precisa saber que os marcadores existem. Uma instrução curta resolve:

```text
Dados pessoais foram substituídos por marcadores como [CPF_1], [EMAIL_1],
[TELEFONE_1], [NOME_1] e [CHAVE_PIX_1]. O mesmo marcador sempre se refere
ao mesmo valor. Use os marcadores exatamente como aparecem, inclusive nos
argumentos das ferramentas. Nunca tente adivinhar, completar ou formatar o
valor real. [CARTAO] indica um número de cartão removido: oriente o cliente
a nunca enviar o número do cartão por mensagem.
```

Sem essa instrução, o Claude às vezes escreve "seu CPF (que foi ocultado)" ou tenta reformatar o marcador. Com ela, o comportamento fica previsível, e a regex tolerante do `restore` cobre os casos em que os colchetes somem.

O histórico da conversa deve ser armazenado **já mascarado**. Isso vale para as mensagens que você reenvia a cada turno, para o resumo gerado pelo modelo e para os fatos extraídos. Se você usa a estratégia de janela e resumo de [memória de agente de IA com janela, resumo e fatos](/blog/como-gerenciar-a-memoria-de-um-agente-de-ia-no-laravel-janela-resumo-e-fatos), o resumo nasce com marcadores e continua coerente enquanto o cofre existir.

O mesmo vale para observabilidade. O trace de cada tool call deve gravar o input **antes** do `restoreDeep` e o resultado **depois** do redator. Assim o replay da conversa, que descrevi em [como rastrear um agente de IA no Laravel](/blog/como-rastrear-um-agente-de-ia-no-laravel-trace-de-cada-tool-call-e-replay-da-conversa), funciona sem que o painel de debug vire um vazamento.

## Onde o mascaramento não resolve?

Vale ser honesto sobre os limites.

1. **Imagens e PDFs.** Comprovante e boleto enviados como imagem vão para o modelo com tudo que está impresso. Dá para fazer OCR local antes e mandar só o texto mascarado, mas aí você perde a robustez da leitura visual do modelo. Na maioria dos fluxos, eu aceito o envio da imagem e reduzo o resto: o que vem *junto* com a imagem vai mascarado.
2. **Dado quase-identificador.** Valor exato, data, hora e nome do estabelecimento, juntos, podem identificar uma pessoa. Mascaramento por tipo não resolve isso. Se o caso exigir, agregue ou arredonde antes de enviar.
3. **Nomes desconhecidos.** O nome do favorecido que o cliente digitou e você nunca viu passa direto. Aceite isso ou invista em NER, sabendo do custo.
4. **Tarefas que dependem do dado.** Se a tarefa é "verificar se o nome no comprovante bate com o titular", mascarar os dois com marcadores consistentes funciona, porque o modelo compara `[NOME_1]` com `[NOME_1]`. Mas grafias diferentes, como abreviações e acentos, quebram a igualdade. Nesses casos, faça a comparação no código e entregue ao modelo só o resultado.

## Como testar o redator?

O redator é código puro e determinístico, então merece teste unitário de verdade, com os casos que mais dão problema:

```php
it('mascara o mesmo CPF em formatos diferentes com o mesmo marcador', function () {
    $vault = new PiiVault();
    $text = (new PiiRedactor())->redact('CPF 529.982.247-25, confirma 52998224725', $vault);

    expect($text)->toBe('CPF [CPF_1], confirma [CPF_1]')
        ->and($vault->restore('[CPF_1]'))->toBe('52998224725');
});

it('não mascara sequência de 11 dígitos com dígito verificador inválido', function () {
    $text = (new PiiRedactor())->redact('Pedido 52998224726', new PiiVault());

    expect($text)->not->toContain('[CPF_');
});

it('remove cartão sem guardar o valor', function () {
    $vault = new PiiVault();
    $text = (new PiiRedactor())->redact('cartão 4111 1111 1111 1111', $vault);

    expect($text)->toBe('cartão [CARTAO]')
        ->and($vault->toArray()[1])->toBeEmpty();
});
```

Além dos testes unitários, uso um teste de regressão simples. Pego um conjunto de mensagens reais já anonimizadas manualmente, rodo o redator e confirmo que nenhum padrão de CPF válido, e-mail ou UUID sobrevive na saída. É barato e pega a regex que alguém "simplificou" num refactor.

## Conclusão: comece pelo executor de ferramentas

Se você vai implementar isso amanhã, a ordem que eu recomendo é esta:

1. Crie o `PiiVault` e o `PiiRedactor` com CPF, e-mail, telefone, EVP e cartão, todos com testes.
2. Passe **toda** entrada do modelo pelo redator: mensagem do usuário, `tool_result` e mensagens de erro.
3. Restaure os marcadores só em dois lugares: nos argumentos das tool calls, dentro do executor, e na resposta final ao usuário.
4. Persista o cofre criptografado por conversa e serialize o processamento por conversa.
5. Grave histórico, resumo e trace já mascarados.

O resultado é um agente que funciona igual para o usuário, mas o provedor de LLM, seus logs e seu painel de debug passam a enxergar `[CPF_1]` em vez do CPF. É a diferença entre explicar ao DPO que "o modelo nunca recebe o CPF" e explicar por que ele recebe.

---

Lucas Roberto é desenvolvedor back-end e fundador da Granpag. Mais textos: https://lucasroberto.com/blog
