joel taylor pedrós
blog

com donar claus d'api a claude code sense que surtin a la conversa

dos requadres. a la conversa només hi ha l'ordre i el resultat; al procés hi ha la clau, amagada amb punts blaus.

tinc 156 credencials guardades, repartides entre la feina i els projectes personals. viuen xifrades a agent-secrets, una eina que vaig publicar el 8 de setembre, i un agent de codi com claude code les pot fer servir sense que el valor arribi mai a la conversa.

aquí explico com funciona per dins, què no fa, i un error que va durar nou dies: quan jo deia que no, l'agent rebia un sí.

per què no un fitxer .env

la manera habitual de donar una contrasenya a un agent és un fitxer .env. l'agent l'obre i el valor queda escrit a la conversa, que viatja a un servidor i es desa en algun lloc. si totes les claus són al mateix fitxer, per donar-ne una les dones totes. i un .env en text pla l'agent el troba sense buscar-lo, amb qualsevol grep pel repositori.

el valor va a un procés, no a l'agent

l'agent no obre cap fitxer de secrets. demana un grup per nom amb secret-run acme openai, seguit de -- i l'ordre que vol executar. acme és un scope, un fitxer .env xifrat amb gpg, i openai és un grup de claus a dins. el programa desxifra l'scope, en treu només aquest grup i executa l'ordre amb aquelles variables a l'entorn. l'agent veu el que imprimeix l'ordre, i la clau no.

el final de la funció que ho fa és gairebé tot el truc:

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

env -i parteix d'un entorn buit i hi posa només les variables del grup. exec substitueix el procés que tenia l'scope sencer desxifrat per l'ordre, que només té el seu grup. cap ordre de l'eina imprimeix un valor, i l'única regla per a l'agent és no executar-hi env, printenv ni set, que existeixen per imprimir l'entorn.

dues execucions de secret-run en una terminal. la primera diu que la clau té 22 caràcters i la segona que la variable existeix, sense mostrar-la.
l'ordre rep la clau i en pot dir la llargada. a la terminal, i per tant a la conversa, no hi arriba.

si una tasca necessita dos grups, es posen tots dos a la mateixa ordre. posar un secret-run dins d'un altre no funciona, i és a propòsit, perquè l'env -i del segon esborra les variables del primer.

llegir els noms sense obrir res

per triar el grup bo, l'agent ha de saber què hi ha. cada scope té un índex en text pla amb els noms dels grups i de les claus, les descripcions i quins grups són delicats, sense cap valor. secret-list el recorre com un ls, un nivell cada vegada.

secret-list amb l'opció tree sobre un scope d'exemple: grups, noms de claus i descripcions, amb un grup marcat com a sensitive i una clau amb un asterisc.
un scope d'exemple. l'agent hi veu què hi ha i per a què serveix, sense desxifrar res.

els rols de postgres porten mode=ro o mode=rw. una tasca de lectura troba el rol de només lectura, que normalment no porta marca i no demana res a ningú.

l'índex no té valors, però és un inventari de quines credencials tinc i per a què serveixen. el xifratge és per a les còpies que surten de la màquina. cada scope és un fitxer gpg autònom que es pot sincronitzar amb un núvol, i la clau viu en un directori a part que no se sincronitza mai. amb AGENT_SECRETS_SYNC_INDEX=0, l'índex tampoc surt.

la clau és de root

sense el pas d'instal·lació de root, la clau és un fitxer del meu usuari, i qualsevol programa que corri com jo la pot llegir i desxifrar-ho tot amb gpg. en aquest mode el diàleg d'aprovació atrapa errors, però no atura res.

install-root.sh ho canvia. copia el programa privilegiat a /usr/local/libexec, propietat de root, passa la clau a root:root amb permisos 600 i afegeix una sola línia a sudoers que permet al meu usuari executar aquell programa sense contrasenya. a partir d'aquí, llegir el fitxer pel meu compte no serveix de res. l'única porta és el programa.

sudoers no pot restringir els arguments d'una ordre, així que totes les comprovacions són dins el programa. valida el nom de l'scope, que és el que impedeix un decrypt ../../root/.ssh/id_ed25519. llegeix del contingut xifrat quins grups són delicats, i no de l'índex, que el meu usuari pot editar. i abans de cridar gpg comprova que només root pot modificar el binari i cada directori per sobre seu. en un mac això descarta el gpg de homebrew, perquè /opt/homebrew és de l'usuari administrador.

no hi ha cap usuari a part. el programa és root només mentre desxifra i decideix, i abans d'executar l'ordre torna al meu usuari amb setpriv a linux o sudo -u a macos.

diagrama en dues franges, el meu usuari a dalt i root a baix. l'agent crida secret-run, que passa per sudo al programa privilegiat; aquest llegeix la clau, pot obrir un diàleg, i torna al meu usuari per executar l'ordre amb només els grups demanats.
la línia blava és l'únic camí del valor. a l'agent hi torna la sortida de l'ordre.

el diàleg d'aprovació

un grup amb #@sensitive al damunt és delicat. si una ordre en demana un, surt un diàleg a la pantalla amb l'scope, què es demana i el motiu que ha escrit l'agent, i l'ordre queda parada fins que responc.

el programa no es fia de les variables de pantalla, perquè una sessió ssh que exporta DISPLAY=:0 arriba a una pantalla davant la qual potser no hi ha ningú. a linux pregunta a loginctl si la sessió gràfica és local, activa i desbloquejada. un diàleg darrere la pantalla de bloqueig el respondria qui la desbloquegés, i no necessàriament jo. el diàleg espera 60 segons, perquè l'eina de bash de l'agent talla les ordres als 120.

un sí dura 15 minuts. des d'octubre, cada ús torna a començar el compte, fins a un màxim de 12 hores des del sí. aquest màxim és una constant del programa de root, i res que corri com jo el pot pujar. cada veredicte va al diari del sistema, l'únic registre que el procés que demana no pot reescriure.

quan dir que no no servia de res

secret-approve aprova d'una vegada els grups que necessitarà una tasca. fins al 17 de setembre acabava així:

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

sembla correcte, però $? llegeix l'estat de l'if, i un if sense else amb una condició que falla acaba amb 0. tot sortia com a èxit, i els missatges d'error de sota no s'imprimien mai.

un agent que aprovava grups per endavant rebia un sí cada vegada que jo clicava no. el valor no sortia, perquè el secret-run següent tornava a passar pel gate, que guardava el no 30 segons o tornava a preguntar. però l'agent creia que tenia permís. l'skill li deia que davant d'un 77, el codi de denegat, parés i em preguntés, i aquell 77 no li havia arribat mai.

va ser així des de la primera versió pública. ho va destapar un test nou de secret-approve que comprovava una altra cosa. la versió bona:

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

set canvis en un dia

el 17 de setembre vaig fusionar set canvis. tres van canviar com s'usa l'eina.

el grup passa a ser obligatori. abans, un secret-run sense grup donava l'scope sencer, i el diàleg havia de preguntar per tots els grups delicats de cop. l'skill de l'agent ja deia que no ho fes, però una regla escrita només aguanta mentre l'agent se'n recorda a mitja tasca, i una que l'eina fa complir no depèn d'això. ara cal escriure --all-groups, i l'error llista els rols de només lectura que no necessiten aprovació.

escriure ja no pregunta per tot. desar una clau nova demanava aprovar tots els grups delicats de l'scope, i així s'aprèn a clicar sí sense llegir. una escriptura no retorna cap valor, i ara només pregunta pel grup on va la clau.

i secret-ask. sovint li demanava a l'agent l'ordre per afegir un secret i hi enganxava el valor, i aleshores quedava a l'historial de la shell, a ps i a la conversa. ara l'agent només diu on va, i surt un diàleg amb el camp emmascarat. el que hi escric va del diàleg al fitxer xifrat sense passar per cap altre lloc.

el que no fa

el readme ho diu a la primera secció. l'eina canvia com un agent arriba a un secret, però no n'atura un que el vulgui agafar. un procés que corre com jo pot fer secret-run sobre qualsevol grup sense marca i enviar el valor on vulgui, sense cap diàleg. pot reescriure les ordres de ~/.local/bin, que són meves. i després d'un sí, el grup queda obert tota la finestra.

és higiene i visibilitat, no contenció. el camí descuidat es torna segur, i el deliberat fa soroll. si una credencial ha de quedar fora de l'abast d'un agent, l'agent ha de córrer amb un altre usuari, o la credencial ha de viure en una altra màquina.

bash 3.2 i gpg

el programa de root ha de funcionar amb el bash 3.2 que porta macos a /bin/bash, l'únic bash d'un mac que és de root. per això no hi ha arrays associatius, ni mapfile, ni referències per nom. on el recurs habitual de bash 3.2 seria un eval, hi ha variables globals, perquè un valor amb una cometa, una barra inversa o un dòlar sortiria d'un eval canviat.

gpg hi posa el xifratge simètric, AES256, i res més. un scope desxifrat és un .env normal amb metadades en comentaris #@, així que amb la clau i un gpg --decrypt es recupera tot sense l'eina.

gpg també va portar l'error més subtil. la clau eren 32 bytes aleatoris, i gpg llegeix el fitxer de contrasenya com a text i s'atura al primer salt de línia. una de cada vuit claus en contenia algun. amb un salt al byte 10, quedaven 80 bits d'entropia en lloc de 256, sense cap avís. ho va trobar la ci quan va tocar el cas del 0,4%, una clau que comença amb un salt de línia i deixa la contrasenya buida. ara la clau és base64, i el test en genera 64 en lloc d'una.

el 17 de setembre eren unes 2.500 línies. el 8 d'octubre, a main, són 4.133 línies de codi sense comptar comentaris ni línies en blanc, més 2.023 de tests, que corren a dues màquines ubuntu i dos macs a cada canvi. el fitxer del programa de root fa 3.036 línies, comentaris inclosos. a la primera versió en feia 757.