Skip to content

fix(seguridad): sacar la key del config de Codex y de Pi - #32

Open
WilberC wants to merge 3 commits into
helmcode:mainfrom
WilberC:security/key-out-of-tool-configs
Open

WilberC wants to merge 3 commits into
helmcode:mainfrom
WilberC:security/key-out-of-tool-configs

Conversation

@WilberC

@WilberC WilberC commented Sep 21, 2026

Copy link
Copy Markdown

Esta rama saca la key de NaN de los dos ficheros de configuración donde hoy está
escrita en texto plano, el config.toml de Codex y el models.json de Pi. En su lugar
queda una referencia a un subcomando nuevo, nan key print, que es la misma idea
que Codex usa para sus propias credenciales: el fichero dice cómo conseguir el
secreto, no cuál es.

El motivo es que esos ficheros se leen todo el tiempo, y por lectores muy
distintos. Los abre el miembro para mirar cualquier otra cosa, los lee un agente
de IA en cuanto le pides que revise o explique la configuración, los pega alguien
en un issue cuando pide ayuda, y acaban en capturas de pantalla. Todo lo que está
dentro queda expuesto a cada uno de esos lectores.

Una key escrita en texto plano ahí no está protegida de nada de eso, porque no se
distingue del resto del texto. Que el fichero esté en 0600 es correcto y no
cambia el problema: los permisos protegen de otra cuenta en la misma máquina, no
de que un secreto aparezca como una línea más entre otras líneas. Un sk-... en
un fichero de configuración se ve, se copia y se propaga exactamente con la misma
facilidad que cualquier otra cadena.

Por eso este cambio no intenta cifrar nada, sino que el secreto deje de estar
escrito en esos ficheros. Y conviene ser preciso con lo que sí y no hace: la key
sigue en texto plano en ~/.config/nan/session.json, con 0600, y nan key print la
imprime cuando se lo piden, así que no desaparece del disco. Lo que cambia es que
deja de ser contenido incidental de los ficheros que se abren, se comparten y se
pegan todos los días, y pasa a estar en un único sitio que nadie abre por
casualidad.

En cuanto al alcance, la key estaba copiada en cinco ficheros y esto la saca de
dos, así que no desaparece de todas partes. A cambio, no añade ninguna copia
nueva: se queda donde ya estaba, en session.json, que nan auth logout ya
borra.

Hay además una consecuencia práctica que no es evidente a primera vista. Con el
diseño anterior, rotar la key obligaba a volver a pasar por nan para que
reescribiera el literal en cada herramienta. Con la referencia a comando, cada
herramienta resuelve la key en el momento del request, así que rotar se reduce a
actualizar session.json y no hay que tocar ningún config.

Qué cambia

Son tres commits, uno por pieza.

0edc326 añade nan key print. Lo que importa de ese comando es cómo falla,
porque quien lo invoca autentica con lo que salga por stdout: si sin sesión
imprimiera una línea vacía y saliera con cero, la herramienta mandaría Bearer
y el fallo se vería como una caída de autenticación en el otro programa, en
lugar de como lo que es. Por eso, cuando no hay sesión, no imprime nada y sale
distinto de cero, y una key vacía se trata igual que no tener sesión. Por el
mismo motivo, la key no entra en ningún mensaje de error.

6fa3c72 cambia el provider de Codex, que pasa a tener una sub-tabla auth con
command y args en vez del token en texto plano. Además, migra los configs que ya
existen.

ec58d4e hace lo propio en Pi, donde providers.nan.apiKey pasa a ser
"!<ruta a nan> key print". De paso, codexAuthCommand se renombra a
nanExecutable, porque ya no lo usa solo Codex.

Lo que medimos antes de escribirlo

Nada de esto salió de leer la documentación, sino de ejecutarlo, y el resultado
cambió el diseño.

Para empezar, auth.command se ejecuta con exec, nunca con un shell. Una
cadena como command = "cat ~/.codex/nan/credentials" no arranca: Codex la trata
entera como la ruta del ejecutable y responde que no encuentra el fichero. Por
eso command es la ruta absoluta pelada y cada argumento va en args, y por eso
un cat, un pipe, un ~ o un && no funcionan nunca, ni en Linux ni en
Windows.

Peor todavía, cuando ese comando falla, Codex manda igual la petición, sin
cabecera Authorization, y reintenta. Es decir, un comando de auth roto se ve
como una caída de autenticación y no como un config mal escrito. De ahí que el
fallo cerrado del subcomando importe tanto.

En cuanto a versiones, auth resuelve el token desde 0.120.0. Antes de eso la
tabla se ignora en silencio y el config carga igual.

Conviene añadir que la documentación de Codex prohíbe combinar auth con
experimental_bearer_token, así que la migración borra la línea en vez de añadir
al lado.

En Pi, en cambio, la documentación dice que un valor que empieza por ! se
ejecuta como comando, y que $$ y $! son $ y ! literales. El escapado va
en ese orden a propósito: primero todos los $ a $$, y después los ! a $!,
porque al revés el $ que inserta el segundo paso se come el escape del primero.

Cómo se migran los configs que ya existen

La migración va en el camino de salida temprana que ya existía, el mismo por el
que pasa la reparación del wire_api. Si se dejara fuera, un miembro que no
volviera a pasar por Setup se quedaría con la key en texto plano para siempre, que es
justo lo que esta rama viene a evitar.

Se hace línea a línea, sin vuelta por una librería TOML, y por el mismo motivo
que la reparación del wire_api: esa vuelta reflowea los comentarios, el
espaciado y las comillas del miembro para cambiar una sola línea.

Por último, la sub-tabla se coloca justo antes de la siguiente cabecera y nunca
antes de claves nuestras. Toda clave escrita después de una sub-tabla le
pertenece, y así fue exactamente como model_context_window acabó dentro de
[projects.*] en su día.

Verificación

Aparte de los tests, lo probamos con los binarios de verdad:

Resultado
Codex 0.120.0, 0.150.0 y 0.155.1 Authorization: Bearer <key> en el cable
Pi 0.86.1, auth check --provider nan {"status":"ready","authType":"api_key"}
Pi 0.86.1, pi -p Authorization: Bearer <key> a /v1/chat/completions
Codex y Pi contra la API real respuesta de glm5.3-flash, código 0 en los dos
Los dos sin sesión nada por stdout, sin cabecera, y fallo visible

La última fila es la que cierra la duda de este cambio. La key la entrega
nan key print en el momento del request en vez de venir escrita en el fichero,
y la API la acepta igual, así que la credencial nueva es equivalente a la vieja y
no solo inocua.

En Pi el models.json lo generó el propio writePiConfig de esta rama y no se
escribió a mano, así que lo que se probó es lo que el writer produce.

Para la migración volcamos los bytes de cuatro formas distintas: la sección al
final del fichero, la sección seguida de un [projects.*], una ya migrada, y
otra con la key vieja y la sub-tabla a la vez. Las cuatro son idempotentes, y las
claves del miembro salen donde entraron.

Cómo encaja con el perfil por modelo

Esto se apoya en el #27, que añade un fichero de perfil por modelo. Un detalle de
esa rama apunta en la misma dirección que esta: los perfiles no llevan la key, y
el motivo está escrito en el propio código, que una key en ocho ficheros es una
key que hay que rotar en ocho ficheros. Las dos cosas se suman, entonces: el
provider de config.toml pasa a llevar solo la referencia, y los perfiles no
llevan nada.

Después de rebasar sobre esa base, la migración y la reparación comparten el mismo
camino de salida temprana, en este orden: primero el pruning de
model_context_window, después la reparación del wire_api y por último la del
token. Comprobado sobre la base nueva, la migración sigue siendo idempotente y los
perfiles que se crean no llevan credencial.

Límite conocido

Una ruta con espacios no se entrecomilla en Pi. Pi resuelve el valor con
variantes de shell según la plataforma y no hay comillas que sirvan en todas, así
que preferimos dejarlo escrito antes que inventar una que solo funcione en una.
Una instalación en /usr/local/bin, que es la que hace install.sh, no se ve
afectada.

Lo que no está probado

Queda la TUI. Los writers se ejercitaron desde sus propias funciones y desde sus
tests, y la cadena completa se probó contra la API real, pero nadie ha pulsado la
pestaña Setup: lo que ata ese botón con el fichero que acaba en el disco es lo
único sin recorrer.

Por qué Claude Code queda fuera

detectTools() no configura Claude Code, así que ahí nunca hubo una key de NaN.
Y tampoco se puede apuntar directamente, porque NaN habla el formato de OpenAI y
Claude Code el de Anthropic. Los dos caminos que documenta nan.builders, delegar
en OpenCode o levantar un gateway LiteLLM local, ya usan
os.environ/NAN_API_KEY y no texto plano. Si algún día se soporta, el mecanismo
equivalente es apiKeyHelper.

Los configs de Codex y de Pi guardan hoy la key en texto plano. La salida de
este cambio es que esos dos ficheros referencien un comando en vez de llevarla
dentro, y para eso hace falta un comando que la saque de donde ya vive:
~/.config/nan/session.json, que esta en 0600 y que nan auth logout ya borra.

Lo que importa del subcomando es como falla. Quien lo invoca lee su stdout y
autentica con lo que salga: si sin sesion imprimiera una linea vacia y saliera
con cero, la herramienta mandaria "Bearer " y el fallo se veria como una caida
de autenticacion en el otro programa, no como esto. Por eso una sesion sin key
se trata igual que no tener sesion - nada por stdout y salida distinta de cero
- y por eso la key no entra en ningun mensaje de error.
El config.toml guardaba la key en texto plano con experimental_bearer_token. Es un
fichero que el miembro pega en un issue cuando pide ayuda, y para quien
gestiona sus dotfiles es ademas un destino de este mismo repo: writeConfigFile
sigue los symlinks a proposito. La key pasa a estar solo en
~/.config/nan/session.json, y Codex la lee por stdout de `nan key print`.

Medido contra Codex de verdad antes de escribirlo, porque la forma importa:
auth.command se ejecuta con exec, nunca con un shell. Una cadena tipo
"cat ~/.codex/nan/credentials" no arranca - "failed to start: No such file or
directory" - y cuando falla Codex manda igual la peticion, sin cabecera
Authorization, y reintenta. Un comando roto se ve como una caida de
autenticacion, no como un config mal escrito. Por eso command es la ruta
absoluta pelada al binario y los argumentos van en args. Verificado de 0.120.0
a 0.155.1.

La doc de Codex prohibe combinar auth con experimental_bearer_token, asi que
la migracion borra la linea en vez de anadir al lado. Va en el camino de
salida temprana que ya existia, el mismo por el que pasa la reparacion del
wire_api: un miembro que no vuelva a pasar por Setup se quedaria con la key
en texto plano para siempre.

Se hace linea a linea, no con una vuelta por una libreria TOML, por el mismo
motivo que la reparacion del wire_api: la vuelta reflowea los comentarios, el
espaciado y las comillas del miembro para cambiar una linea. Y la sub-tabla va
justo antes de la siguiente cabecera, nunca antes de claves nuestras: toda
clave escrita despues de una sub-tabla le pertenece, que es como
model_context_window acabo dentro de [projects.*].

De paso, removeCodexConfig cortaba la seccion en la primera cabecera pero se
quedaba con ella, asi que un [model_providers.nan.auth] sobrevivia al sign-out
y Codex seguia resolviendo una key que ya no existe. Ahora reconoce las dos
cabeceras, y tambien la forma entrecomillada que el resto del fichero ya
aceptaba.
models.json guardaba la key en texto plano dentro de providers.nan.apiKey, con el mismo
problema que el config de Codex: es un fichero que el miembro pega en un issue
y que para quien gestiona dotfiles vive en un repo. Pi acepta un valor que
empieza por "!" y ejecuta el resto como comando, asi que el provider apunta a
`nan key print` y la key se queda solo en ~/.config/nan/session.json.

De la doc de Pi, el valor admite ademas $$ como "$" literal y $! como "!"
literal, y el escapado se hace en ese orden a proposito: primero todos los $
a $$, y despues los ! a $!. Al reves, el $ que inserta el segundo paso se
comeria el escape del primero.

nanExecutable pasa a llamarse asi porque ya no lo usa solo Codex.

Queda un limite conocido: una ruta con espacios no se entrecomilla. Pi resuelve
el valor con variantes de shell segun la plataforma y no hay comillas que
sirvan en todas, asi que se deja escrito en vez de inventar una que funcione en
una sola. Una instalacion en /usr/local/bin no lo toca.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants