Pasé 25 Años Evitando Malware. El Código de Claude Almacenó 600 de Mis Secretos de Todas Formas.
No he detectado ni una sola pieza de malware en 25 años frente al teclado. Ni una. Detecto un .scr disfrazado de PDF desde el otro lado de la habitación. Huelo un script postinstall sospechoso a diez metros de distancia. A los 14 años incluso escribí dos o tres virus, solo para entender la mecánica (la biología me fascinaba: replicación, mutación, persistencia). Al atacante, lo conozco desde adentro.
Esta mañana audité mi directorio home de los últimos 12 meses. 600 secretos en texto plano en mi disco 😬. GitHub PATs, tokens OAuth, claves AWS, Google API, JWTs, todo el buffet. No en un .env olvidado en un repo público. No en un commit mal hecho. En archivos JSONL enterrados dentro de ~/.claude, un directorio cuya existencia apenas registré hace dos semanas.
Esto no es un mea culpa sobre mala higiene. Hace quince años tenía 100 contraseñas en Keychain y era suficiente. Hoy cargamos docenas de claves API, las herramientas las registran sin decirnos, y mi disciplina de 25 años nunca se calibró para esto.
La regla antigua, "ten cuidado, no hagas clic en cosas random", asume un atacante que viene por mí. Esa amenaza sigue ahí. Pero apareció una segunda categoría: el atacante que no viene por mí para nada, solo ejecutándose con mis privilegios, plantado por una dependencia transitiva de npm que nunca audité. Aterriza en un directorio home que ahora contiene un museo de todo lo que le mostré a un asistente.
Mucho más peligroso. Hora de reaccionar.

La auditoría: 5,904 archivos, 171 tocados, 600 secretos en texto plano
Ejecuté el escaneo contra ~/.claude/{projects,tasks,sessions,todos,shell-snapshots,paste-cache,file-history,debug} más ~/.zsh_history y ~/.bash_history. 5,904 archivos en total. 1.1 GB de peso acumulativo. 171 de esos archivos contenían al menos una credencial.
El desglose se ve más o menos así:
- 95 tokens OAuth de GitHub (
gho_) - 94 GitHub fine-grained PATs (
github_pat_) - 103 claves API de Google (
AIza) - 197 strings con forma de JWT (
eyJ) - 45 claves de acceso AWS (
AKIA) - 18 OpenRouter
- 15 Resend
- 7 Anthropic OAuth
- 6 tokens de bot de Telegram
- 3 claves de test de Stripe
- 2 tokens de Vercel
Aproximadamente 600 secretos en 171 archivos. Casi todos rotables, muchos ya rotados para cuando escribo esto. Una salvedad que pondré aquí en lugar de enterrar al final: el bucket JWT_LIKE es ruidoso, incluye claves públicas de Supabase que son públicas por diseño. Asumo los falsos positivos. Un falso positivo me cuesta una redacción. Un falso negativo me cuesta una credencial.
Leer el JSONL se sintió como abrir un archivo de autoguardado de un roguelike que nunca supe que estaba jugando. Cada comando, cada paste, cada lectura, persistido para siempre en el orden que pasó. La mazmorra persistente de NetHack, excepto que el botín son mis claves AWS.
Otro desarrollador reportó públicamente una versión más pequeña del mismo problema en GitHub issue #50014 el 17 de abril: 5 secretos distintos en 34 archivos de sesión después de aproximadamente 30 días de uso, 418 MB en total.
30 días, 5 secretos. 12 meses, 600. Una relación lineal que me parece demasiado creíble.
Así que la pregunta no es si esto pasa. Es cómo pasa, y qué defiende contra ello.
Por qué pasa: cinco caminos hacia una transcripción en texto plano
El mecanismo es mecánico, no misterioso. Cada sesión de Claude Code escribe un archivo JSONL en ~/.claude/projects/<project-hash>/<session-id>.jsonl. Cada línea es un registro: un mensaje de usuario, una respuesta del asistente, una llamada de herramienta, un resultado de herramienta. El archivo es append-only. Nada lo poda. Nada lo limpia. Se queda ahí tanto como quieras, y en la mayoría de Macs eso significa para siempre.
Cinco caminos llevan un secreto a ese archivo:
-
Salida de Bash de un comando legítimo.
infisical secrets get MY_TOKEN --plain,gh auth token,vercel token,cat .env,security find-generic-password -w,printenv | grep TOKEN,echo $SECRET. Cualquier cosa que imprima una credencial a stdout, el JSONL la registra. -
Paste manual tuyo, en el chat. Sueltas un token en el prompt para pedirle a Claude que lo use. El token ahora es parte del registro user-message para siempre.
-
Lectura de archivo a través de la herramienta
Read. Le pides a Claude que mire.envpara contexto. El contenido del archivo aterriza en el registro tool-result. -
Escritura de archivo con un secreto hardcodeado. Le pides a Claude que arme una config y el secreto termina en el contenido del nuevo archivo. Bonus: el mismo contenido se duplica bajo
~/.claude/file-history/. -
Display explícito por Claude en una respuesta. Preguntas "¿cuál es el valor?", Claude lo imprime de vuelta. La respuesta está en el registro assistant-message.
Cinco caminos, un archivo, append-only, texto plano. Sin purga, sin rotación, sin escaneo.
Ahora el pivot. La defensa antigua, "no pegues tus secretos en lugares random, no ejecutes comandos sketchy", se construyó alrededor de un atacante que te ataca. Esa defensa todavía funciona contra ese atacante. El problema es que apareció una categoría diferente, y camina alrededor de la defensa antigua sin romperla.
Tracé el hijack de LiteLLM a mi propio cache de pip hace ocho meses, y la lección ya estaba ahí: un paquete envenenado aterriza con los privilegios del usuario y empieza a leer lo que el usuario puede leer. En marzo de 2026, la campaña TeamPCP envenenó 75 tags de GitHub Action y empujó payloads maliciosos a 141+ paquetes npm a través de secretos robados de CI/CD. En abril de 2026, investigadores de Check Point encontraron 33 paquetes npm enviando públicamente archivos .claude/settings.local.json con credenciales inline. GitHub PATs, tokens de Telegram, bearer tokens de producción, todo.
Tres campañas diferentes. Misma mecánica. El atacante no está tocando mi puerta. Es un script ejecutándose con mis privilegios, soltado por una dependencia que nunca audité directamente. Y una vez que ese script está vivo en mi directorio home, mis 1.1 GB de transcripciones de Claude Code son una mina de oro.
El reporte 2026 de GitGuardian pone la tendencia más amplia en números: las exposiciones de credenciales de servicios AI detectadas en GitHub público saltaron 81% año contra año. Los commits asistidos por Claude Code filtran secretos a aproximadamente 3.2%, contra 1.5% para la línea base de commits públicos. Los commits acelerados por AI filtran secretos al doble de la tasa. El cambio es de toda la industria, no solo mi disco.
Así que aquí está el pivot, claro.
La disciplina defiende contra atacantes que te atacan. Las barreras mecánicas defienden contra atacantes que se ejecutan como tú.
Incluso tu vault filtra en el momento que hace su trabajo
Tenía Infisical. Tenía reglas de deny. Tenía inyección en runtime. Todavía tenía 600 secretos en texto plano.
La historia es molesta porque la higiene era correcta. El secreto no duerme en un .env. Vive en un vault encriptado. Y sin embargo.
La mecánica es el paso de resolución mismo. El secreto sale del vault por dos segundos para hacer su trabajo, y esos dos segundos existen en algún lugar: en texto plano en la salida de bash, en texto plano en un env de proceso, en texto plano en un paste manual. Durante esos dos segundos, Claude Code está escuchando. El JSONL toma notas.
Mira la diferencia entre dos comandos que se ven idénticos:
infisical secrets get MY_TOKEN --plain
curl -H "Authorization: Bearer $(cat last_token.txt)" https://api...
MY_TOKEN=$(infisical secrets get MY_TOKEN --plain)
curl -H "Authorization: Bearer $MY_TOKEN" https://api...
Un carácter de diferencia. Dos destinos opuestos. El primero escribe el token a la transcripción. El segundo nunca lo deja tocar stdout.
Un vault es una cerradura. Una transcripción JSONL es un museo. Ambos guardan el secreto. Solo uno lo mantiene en exhibición.
La defensa de 4 capas que tuve que construir desde cero

El orden importa. La capa más temprana se dispara antes de que el secreto toque el disco. La capa más tardía limpia lo que se filtró. Quieres ambas. Piénsalo como organizar una raid: cada fase corta la superficie de ataque para la siguiente, y la última fase limpia el loot que el boss dejó caer en el piso.
Capa 1: Hook PreToolUse en Bash. Intercepta los patrones riesgosos antes de que el comando se ejecute. infisical secrets get --plain sin pipe, gh auth token sin pipe, vercel token, security find-generic-password -w sin pipe, cat .env|.envrc|.netrc|.npmrc, printenv|env grepeando token|secret|key|password, echo $VAR_SECRET. El hook devuelve un JSON permissionDecision: ask con un mensaje que explica el patrón seguro. No un bloqueo estricto. Un falso positivo no debe romper el workflow, sino deshabilitaré el hook en una semana y ambos lo sabemos.
Capa 2: Hook UserPromptSubmit. Escanea el texto que estoy enviando antes de que entre a la transcripción. Match significa decision: block. El secreto pegado nunca llega al JSONL. Mismo set de regex que las otras capas, más un patrón extra para URLs completas con credenciales embebidas.
Capa 3: Hook SessionEnd + scrubber. Cuando una sesión termina limpiamente, el hook hace glob-match de ~/.claude/projects/*/<session_id>.jsonl, limpia el archivo in-place, valida que cada línea siga siendo JSON válido, escribe atómicamente (archivo tmp más os.replace). Esto baja la ventana de filtración de 24 horas a unos pocos segundos.
Capa 4: Cron diario a las 04:00. Red para sesiones que murieron sin gracia. kill -9, crash, pérdida de energía, cualquier cosa donde SessionEnd nunca se disparó. El cron camina los mismos paths y hace la misma limpieza. Cinturón y tirantes.
Una regla de comportamiento en la memoria de Claude ("no imprimas secretos a stdout") es la capa cero, la más débil. La mantengo como una pista cortés. La auditoría de 600 secretos es prueba suficiente de que la disciplina del modelo eventualmente se dobla.
Algunas decisiones de diseño que importan para cualquiera que quiera copiar esto:
El scrubber es Python 3, solo stdlib. Cero dependencias. /usr/bin/python3 nunca se rompe durante una actualización de Homebrew. El pattern matching está anclado en prefijos conocidos: sk-ant-api, ghp_, github_pat_, AKIA, gho_. Sin regex genérico de "20 caracteres alfanuméricos", eso generaría falsos positivos en cada UUID, hash y chunk base64 en el archivo.
El reemplazo es determinístico con una huella sha256: [REDACTED-GITHUB_PAT_CLASSIC-a1b2c3d4]. Mismo secreto en dos lugares se redacta a la misma huella. Puedo trackear duplicados sin nunca almacenar el valor mismo. La validez JSON se preserva, la sustitución pasa dentro del campo string existente, la estructura se mantiene intacta.
Sin backups pre-scrub. Un archivo de backup es solo otra copia del secreto, y un ladrón lo lee tan fácilmente como el original.
Capturé la primera señal de toda esta madriguera de conejo hace un mes cuando Claude Code se negó a copiar mis propios secretos durante un move de carpeta. settings.local.json apareció con credenciales en texto plano, en un lugar que nunca pensé mirar. La auditoría que describo aquí empezó porque me negué a creer que ese era el único lugar.
El código completo está en GitHub.
El scrubber no necesita ser cuidadoso. Se ejecuta. El hook no necesita ser inteligente. Bloquea.
Qué todavía se filtra, y la nueva regla por la que vivo
La defensa es un perímetro, no una pared. Lista honesta de lo que se escapa:
Secretos sin un prefijo identificable. URLs de la forma https://user:pass@host. Contraseñas arbitrarias. UUID v4 usado como bearer tokens. Patrones anclados significan buena precisión pero cobertura parcial. Acepto ese tradeoff porque la alternativa es un regex que flagea cada string base64 y se deshabilita en un día.
Ofuscación de Bash. eval $(echo "infisical secrets get X --plain"), aliases custom que wrappean el comando peligroso, cualquier cosa indirecta. La Capa 1 atrapa los patrones directos. No atrapa a un ofuscador determinado (que, para ser justo, soy principalmente yo tratando de probar que el hook está mal).
El scope es estrictamente Claude. ~/.claude, ~/.zsh_history, ~/.bash_history. Fuera de scope: ~/.aws/credentials, ~/.ssh/id_*, Keychain vía security, vars env en procesos corriendo, cookies del browser. Otras superficies son otros proyectos.
Falsos positivos en JWT_LIKE. Las claves públicas de Supabase se redactan innecesariamente. Prefiero perder unas pocas claves públicas a redacción que perderme una real.
Así que aquí está la nueva regla.
La regla antigua era "ten cuidado, no hagas clic en cosas random". Asumía un atacante remoto o humano. Todavía válida para esa categoría, y no la estoy tirando.
La nueva regla es diferente.
Nada secreto sobrevive en texto plano en este disco.
No por paranoia. Por pragmatismo. Mañana un script se ejecutará con mis privilegios, soltado por una dependencia tres capas profundo que nunca revisé a mano, y la única defensa que aguanta es mecánica.
Seis meses desde ahora
Un vendor anunciará "filtración masiva de credenciales vía transcripciones de asistente AI" y todos se verán sorprendidos. Habrá un post corporativo con culpa vaga a una capa de almacenamiento de terceros. Habrá threads explicando que obviamente deberías haber toggleado esa configuración. Todos dirán que fue un caso aislado.
Mientras tanto algunos de nosotros estamos auditando nuestros discos. Hackeando hooks juntos. Leyendo el source cuando filtra. Construyendo redes. No por paranoia. Por pragmatismo.
La regla antigua asumía un humano del otro lado. Eso dejó de ser el default hace rato.
La siguiente capa de defensa es enseñarle a nuestras AIs a comportarse. Nuestros nuevos hijos digitales, ¿no?