---
title: "cómo dar claves de api a claude code sin que salgan en el chat"
description: "agent-secrets guarda las claves de api cifradas con gpg y se las pasa al comando que las necesita, no al agente. cómo funciona con claude code y qué no protege."
date: 2026-10-08
author: "joel taylor pedrós"
lang: es
url: https://joeltaylor.business/blog/claves-api-claude-code
translations:
  ca: https://joeltaylor.business/blog/claus-api-claude-code
  en: https://joeltaylor.business/blog/api-keys-claude-code
image: https://joeltaylor.business/_next/static/media/cover.es.0tdqrg-pt8vpm.webp
---

# cómo dar claves de api a claude code sin que salgan en el chat

![dos recuadros. en la conversación solo están el comando y el resultado; en el proceso está la clave, tapada con puntos azules.](https://joeltaylor.business/_next/static/media/cover.es.0tdqrg-pt8vpm.webp)

tengo 156 credenciales guardadas, claves de api incluidas, repartidas entre el trabajo y los proyectos personales. viven cifradas en [agent-secrets](https://github.com/jtayped/agent-secrets), una herramienta que publiqué el 8 de septiembre, y un agente de código como claude code puede usarlas sin que el valor llegue nunca a la conversación.

aquí explico cómo funciona por dentro, qué no hace, y un error que duró nueve días: cuando yo decía que no, el agente recibía un sí.

## por qué no un fichero .env

la forma habitual de darle una contraseña a un agente es un fichero `.env`. el agente lo abre y el valor queda escrito en la conversación, que viaja a un servidor y se guarda en algún sitio. si todas las claves están en el mismo fichero, para dar una las das todas. y un `.env` en texto plano el agente lo encuentra sin buscarlo, con cualquier `grep` por el repositorio.

## el valor va a un proceso, no al agente

el agente no abre ningún fichero de secretos. pide un grupo por su nombre con `secret-run acme openai`, seguido de `--` y el comando que quiere ejecutar. `acme` es un scope, un fichero `.env` cifrado con gpg, y `openai` es un grupo de claves que hay dentro. el programa descifra el scope, saca solo ese grupo y ejecuta el comando con esas variables en el entorno. el agente ve lo que imprime el comando, y la clave no.

el final de la función que lo hace es casi todo el truco:

```bash
exec "${drop[@]}" \
    env -i HOME="$target_home" USER="$target_user" LOGNAME="$target_user" \
    PATH="$child_path" ${env_args[@]+"${env_args[@]}"} "$@"
```

`env -i` parte de un entorno vacío y mete en él solo las variables del grupo. `exec` sustituye el proceso que tenía el scope entero descifrado por el comando, que solo tiene su grupo. ningún comando de la herramienta imprime un valor, y la única regla para el agente es no ejecutar a través de ella `env`, `printenv` ni `set`, que existen para imprimir el entorno.

![dos ejecuciones de secret-run en una terminal. la primera dice que la clave tiene 22 caracteres y la segunda que la variable existe, sin mostrarla.](https://joeltaylor.business/_next/static/media/secret-run.0x6bi1rxv1byv.webp)

_el comando recibe la clave y puede decir cuánto mide. a la terminal, y por tanto a la conversación, no llega._

si una tarea necesita dos grupos, se ponen los dos en el mismo comando. meter un `secret-run` dentro de otro no funciona, y es a propósito, porque el `env -i` del segundo borra las variables del primero.

## leer los nombres sin abrir nada

para elegir el grupo correcto, el agente tiene que saber qué hay. cada scope tiene un índice en texto plano con los nombres de los grupos y de las claves, las descripciones y qué grupos son delicados, sin ningún valor. `secret-list` lo recorre como un `ls`, un nivel cada vez.

![secret-list con la opción tree sobre un scope de ejemplo: grupos, nombres de claves y descripciones, con un grupo marcado como sensitive y una clave con un asterisco.](https://joeltaylor.business/_next/static/media/arbre.3uvrb__vwn5jc.webp)

_un scope de ejemplo. el agente ve qué hay y para qué sirve, sin descifrar nada._

los roles de postgres llevan `mode=ro` o `mode=rw`. una tarea de lectura encuentra el rol de solo lectura, que normalmente no lleva marca y no le pide nada a nadie.

el índice no tiene valores, pero es un inventario de qué credenciales tengo y para qué sirven. el cifrado es para las copias que salen de la máquina. cada scope es un fichero gpg autónomo que se puede sincronizar con una nube, y la clave vive en un directorio aparte que no se sincroniza nunca. con `AGENT_SECRETS_SYNC_INDEX=0`, el índice tampoco sale.

## la clave es de root

sin el paso de instalación de root, la clave es un fichero de mi usuario, y cualquier programa que corra como yo puede leerla y descifrarlo todo con gpg. en ese modo el diálogo de aprobación pilla errores, pero no detiene nada.

`install-root.sh` lo cambia. copia el programa privilegiado a `/usr/local/libexec`, propiedad de root, pasa la clave a `root:root` con permisos 600 y añade una sola línea a sudoers que permite a mi usuario ejecutar ese programa sin contraseña. a partir de ahí, leer el fichero por mi cuenta no sirve de nada. la única puerta es el programa.

sudoers no puede restringir los argumentos de un comando, así que todas las comprobaciones están dentro del programa. valida el nombre del scope, que es lo que impide un `decrypt ../../root/.ssh/id_ed25519`. lee del contenido cifrado qué grupos son delicados, y no del índice, que mi usuario puede editar. y antes de llamar a gpg comprueba que solo root puede modificar el binario y cada directorio por encima de él. en un mac eso descarta el gpg de homebrew, porque `/opt/homebrew` es del usuario administrador.

no hay ningún usuario aparte. el programa es root solo mientras descifra y decide, y antes de ejecutar el comando vuelve a mi usuario con `setpriv` en linux o `sudo -u` en macos.

![diagrama en dos franjas, mi usuario arriba y root abajo. el agente llama a secret-run, que pasa por sudo al programa privilegiado; este lee la clave, puede abrir un diálogo, y vuelve a mi usuario para ejecutar el comando solo con los grupos pedidos.](https://joeltaylor.business/_next/static/media/flux.es.320xk4-vh5cms.webp)

_la línea azul es el único camino del valor. al agente le vuelve la salida del comando._

## el diálogo de aprobación

un grupo con `#@sensitive` encima es delicado. si un comando pide uno, sale un diálogo en la pantalla con el scope, qué se pide y el motivo que ha escrito el agente, y el comando se queda parado hasta que respondo.

el programa no se fía de las variables de pantalla, porque una sesión ssh que exporta `DISPLAY=:0` llega a una pantalla delante de la cual quizá no hay nadie. en linux le pregunta a `loginctl` si la sesión gráfica es local, está activa y está desbloqueada. un diálogo detrás de la pantalla de bloqueo lo respondería quien la desbloqueara, y no necesariamente yo. el diálogo espera 60 segundos, porque la herramienta de bash del agente corta los comandos a los 120.

un sí dura 15 minutos. desde octubre, cada uso reinicia la cuenta, hasta un máximo de 12 horas desde el sí. ese máximo es una constante del programa de root, y nada que corra como yo puede subirlo. cada veredicto va al diario del sistema, el único registro que el proceso que pide no puede reescribir.

## cuando decir que no no servía de nada

`secret-approve` aprueba de una vez los grupos que necesitará una tarea. hasta el 17 de septiembre terminaba así:

```bash
if secret_helper gate "$scope" --motive "$motive" "$@"; then
    exit 0
fi
rc=$?
```

parece correcto, pero `$?` lee el estado del `if`, y un `if` sin `else` con una condición que falla termina con 0. todo salía como éxito, y los mensajes de error de debajo no se imprimían nunca.

un agente que aprobaba grupos por adelantado recibía un sí cada vez que yo pulsaba no. el valor no salía, porque el `secret-run` siguiente volvía a pasar por el gate, que guardaba el no 30 segundos o volvía a preguntar. pero el agente creía que tenía permiso. la skill le decía que, ante un 77, el código de denegado, parara y me preguntara, y ese 77 no le había llegado nunca.

fue así desde la primera versión pública. lo destapó un test nuevo de `secret-approve` que comprobaba otra cosa. la versión buena:

```bash
rc=0
secret_helper gate "$scope" --motive "$motive" "$@" || rc=$?
[[ $rc -eq 0 ]] && exit 0
```

## siete cambios en un día

el 17 de septiembre fusioné siete cambios. tres cambiaron cómo se usa la herramienta.

el grupo pasa a ser obligatorio. antes, un `secret-run` sin grupo daba el scope entero, y el diálogo tenía que preguntar por todos los grupos delicados de golpe. la skill del agente ya le decía que no lo hiciera, pero una regla escrita solo aguanta mientras el agente se acuerda de ella a media tarea, y una que la herramienta hace cumplir no depende de eso. ahora hay que escribir `--all-groups`, y el error lista los roles de solo lectura que no necesitan aprobación.

escribir ya no pregunta por todo. guardar una clave nueva pedía aprobar todos los grupos delicados del scope, y así se aprende a pulsar sí sin leer. una escritura no devuelve ningún valor, y ahora solo pregunta por el grupo donde va la clave.

y `secret-ask`. a menudo le pedía al agente el comando para añadir un secreto y pegaba en él el valor, y entonces quedaba en el historial de la shell, en `ps` y en la conversación. ahora el agente solo dice dónde va, y sale un diálogo con el campo enmascarado. lo que escribo va del diálogo al fichero cifrado sin pasar por ningún otro sitio.

## lo que no hace

el readme lo dice en la primera sección. la herramienta cambia cómo llega un agente a un secreto, pero no detiene a uno que quiera cogerlo. un proceso que corre como yo puede hacer `secret-run` sobre cualquier grupo sin marca y enviar el valor adonde quiera, sin ningún diálogo. puede reescribir los comandos de `~/.local/bin`, que son míos. y después de un sí, el grupo queda abierto toda la ventana.

es higiene y visibilidad, no contención. el camino descuidado se vuelve seguro, y el deliberado hace ruido. si una credencial tiene que quedar fuera del alcance de un agente, el agente tiene que correr con otro usuario, o la credencial tiene que vivir en otra máquina.

## bash 3.2 y gpg

el programa de root tiene que funcionar con el bash 3.2 que trae macos en `/bin/bash`, el único bash de un mac que es de root. por eso no hay arrays asociativos, ni `mapfile`, ni referencias por nombre. donde el recurso habitual de bash 3.2 sería un `eval`, hay variables globales, porque un valor con una comilla, una barra invertida o un dólar saldría de un `eval` cambiado.

gpg pone el cifrado simétrico, `AES256`, y nada más. un scope descifrado es un `.env` normal con metadatos en comentarios `#@`, así que con la clave y un `gpg --decrypt` se recupera todo sin la herramienta.

gpg también trajo el error más sutil. la clave eran 32 bytes aleatorios, y gpg lee el fichero de contraseña como texto y se detiene en el primer salto de línea. una de cada ocho claves contenía alguno. con un salto en el byte 10, quedaban 80 bits de entropía en lugar de 256, sin ningún aviso. lo encontró la ci cuando le tocó el caso del 0,4%, una clave que empieza con un salto de línea y deja la contraseña vacía. ahora la clave es base64, y el test genera 64 en lugar de una.

el 17 de septiembre eran unas 2.500 líneas. el 8 de octubre, en main, son 4.133 líneas de código sin contar comentarios ni líneas en blanco, más 2.023 de tests, que corren en dos máquinas ubuntu y dos macs en cada cambio. el fichero del programa de root tiene 3.036 líneas, comentarios incluidos. en la primera versión tenía 757.
