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

tengo 156 credenciales guardadas, claves de api incluidas, repartidas entre el trabajo y los proyectos personales. viven cifradas en 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:
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.

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.

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.

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í:
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:
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.