gioser

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

paso 9: a gnupg no le sobraba nada, le FALTABA pinentry — y `git commit -S` no andaba en ninguna imagen

El plan preguntaba si gnupg se reemplaza por sequoia o se borra, sobre dos premisas que medí y son
falsas. (a) «0 consumidores»: es 0 por BUILD, y correcto — nadie lo enlaza. Pero a gpg se lo EJECUTA.
Barrido del store entero (66 G) buscando /usr/bin/gpg, gpgconf, gpg-agent: 147 ficheros, 13 paquetes;
dentro de imágenes son git (LOS 7 perfiles, commit -S / tag -s), lazygit (6, vía commit.gpgSign) y gcr
(gnome, con /usr/bin/gpg HORNEADO en la .so y sus 19 símbolos _gcr_gnupg_*). Es ifconfig otra vez:
para un binario que se ejecuta, `yupana radio` es un piso, no el número.

(b) Y lo que viajaba estaba a medias inerte. La batería contra los artefactos sellados: verificar,
gpgv, simétrico, asimétrico y firmar con clave SIN contraseña, todo ✅; firmar con clave CON
contraseña ⇒ `gpg: signing failed: No pinentry`, y `git commit -S` ⇒ `fatal: failed to write commit
object`. pinentry NO existía en el catálogo, y gpg-agent lo busca por RUTA (bindir/pinentry ⇒
/usr/bin/pinentry, comprobado contra el binario) así que no hay config que lo salve. 39,1 MiB en 7
perfiles con la operación más común rota — la misma forma que el setuid del paso 2.

⇒ Ni borrar ni reemplazar: COMPLETAR. recipes/pinentry.toml, b3:18161b35…, 1,1 MiB, curses+tty
estáticos musl y el /usr/bin/pinentry→pinentry-curses de upstream. Con un agente nuevo y sin caché:
detach-sign SALIDA=0 y `git commit -S` SALIDA=0, firmas verificadas. La prueba buena es dentro de un
bwrap cuyo / se hidrató SÓLO con artefactos del corpus, que es donde esa ruta es la del store.
Entra a `base` como raíz (hueco de runtime puro, nadie lo alcanza por deps) ⇒ 0 reconstrucciones.

El guardián: el boot-test corría `gpg --version` y daba verde con esto roto. Ahora firma con clave
protegida y lee el error, que discrimina sin terminal — «Not a tty» = pinentry está y se lanzó,
«No pinentry» = falta — y es condición de aprobado. Medido en los dos sentidos, con un control al que
se le borran los tres binarios.

Corrección al 2.3: sequoia NO es el reemplazo. Ningún consumidor quiere una librería ni una CLI nueva:
git ejecuta gpg.program hablando su protocolo y gcr tiene la ruta horneada. Sería gpg-sq, no sq. Y
`gpg.format=ssh` no salva: openssh sólo viaja en `servidor`.

Queda nombrado y sin resolver: cap_ipc_lock (la pone el ensamblado, como el setuid), sin dirmngr
(--recv-keys ⇒ «probably not installed», medido), sin scdaemon (--card-status ⇒ «Not supported»),
sin gpgtar; y que el BASE_SYSTEM del boot-test todavía trae `sudo`, que salió de base en el paso 2.

e52abccc21174fe42b930fa04a3efe56c6ebfd7b · por Sergio · (hace 29 d) · padre deade2633 · ver el árbol en este commit

Delta semántico

Ninguna definición cambió: el cambio es de texto, documentación o datos.

4 archivos · +237 −5

diff --git a/docs/plan-que-sigue-tras-busybox.md b/docs/plan-que-sigue-tras-busybox.md
index 05b3b073..5ce9c0c4 100644
--- a/docs/plan-que-sigue-tras-busybox.md
+++ b/docs/plan-que-sigue-tras-busybox.md
@@ -189,6 +189,13 @@ QEMU, no que compile.
 
 ### 2.3 · `gnupg` — mirar si hay que reemplazarlo o **borrarlo**
 
+✅ **RESPONDIDO el 2026-09-22 en el paso 9, y la respuesta es NINGUNA DE LAS DOS: se completa.**
+Lo de abajo se deja como estaba porque el razonamiento es el que había, pero sus dos premisas
+están medidas y son falsas: (a) los «0 consumidores» son 0 **por build** — por runtime lo ejecutan
+`git` (7 perfiles), `lazygit` (6) y `gcr` (gnome, con la ruta horneada); (b) no lo invoca «un solo
+script de prueba»: lo invoca git cada vez que alguien firma un commit. Y lo que viajaba no podía
+usar ninguna clave con contraseña porque **faltaba `pinentry`**. Ver paso 9.
+
 `gnupg` es raíz de `base` (`targets.toml:60`) ⇒ viaja en los 7 perfiles, 39,1 MiB, **0 consumidores**, y en todo el repo lo invoca **un
 solo script de prueba** (`scripts/product-base-system-boot-test.sh`). La firma de takana **no lo
 usa**: es ed25519 propio (`takana keygen release`, `trust/`). Entonces son dos preguntas distintas:
@@ -342,6 +349,7 @@ ajeno sin decirlo. ⚠ Y el ADR 0018 deja abierta la de Secure Boot, que es la q
 | 6 | **ADR 0012** | — | la granja deja de compilar de a uno |
 | 7 | `meson` → `muon` (medir 10 antes) | 107 | un intérprete menos en el sandbox |
 | 8 | `zlib-rs` en UN consumidor, para decidir | 1 | la lib C más consumida del corpus, con la misma ABI |
+| 9 | `gnupg`: ¿reemplazar o borrar? | 0 | **respondido: ninguna de las dos** — le faltaba `pinentry` y `git commit -S` no funcionaba en ninguna imagen |
 
 Los cinco primeros **no cuestan una sola reconstrucción**. Eso es lo que los pone antes: la lección
 de `uutils`/`brush`/`foot` —trabajo hecho y no cobrado— aplicada al calendario.
@@ -1126,3 +1134,123 @@ fuentes. Lo que queda para decidir la campaña de las 122 no es técnico sino de
 ⇒ Y de ahí sale la recomendación concreta, que antes de medir no se podía dar: **empezar por los
 consumidores DINÁMICOS**, donde el coste en bytes es una sola librería compartida y el unwinder no
 hace falta. Los estáticos pagan 2,9 MB cada uno.
+
+---
+
+# Paso 9 — HECHO: `gnupg` no se borra ni se reemplaza, se COMPLETA — le faltaba `pinentry` (2026-09-22)
+
+El paso 2.3 de este plan preguntaba si `gnupg` se reemplaza por sequoia o se borra, con esta premisa:
+«raíz de `base` ⇒ los 7 perfiles, 39,1 MiB, **0 consumidores**, y en todo el repo lo invoca un solo
+script de prueba». **Las dos mitades de esa premisa son falsas, y medirlas cambió la respuesta.**
+
+## 1 · Los «0 consumidores» son 0 por BUILD, y tres por RUNTIME
+
+`yupana radio gnupg` dice 0 dependientes directos y 0 transitivos — y es correcto: **nadie lo enlaza**.
+Pero a gpg no se lo enlaza, se lo EJECUTA, y eso el grafo de build no lo puede ver. Barrido del store
+entero (66 G, todo ejecutable o `.so`, buscando `/usr/bin/gpg`, `gpgconf`, `gpg-agent`, `gpg --`):
+**147 ficheros, 13 paquetes distintos**. Filtrados por membresía de imagen:
+
+| paquete | perfiles | cómo lo usa |
+|---|---|---|
+| `git` | **los 7** | `commit -S`, `tag -s`, `verify-commit` — por `gpg.program` |
+| `lazygit` | 6 | no llama a gpg: pone `commit.gpgSign`/`tag.gpgSign` y deja que git lo haga |
+| `gcr` | escritorio-gnome | **`/usr/bin/gpg` HORNEADO en la `.so`** (`-Dgpg_path=`), con sus 19 símbolos `_gcr_gnupg_process_*`/`_gcr_gnupg_importer_*`: es el importador de claves PGP del escritorio |
+
+Los otros diez (`gh`, `terragrunt`, `chezmoi`, `sops`, `gopass`, `cosign`, `dnscontrol`, `infracost`,
+`kluctl`, `terramate`, `gocron`) están en el catálogo y en **ninguna** imagen, así que no deciden nada
+hoy. Es la lección de `ifconfig` del censo de busybox otra vez, y vale escribirla como regla: **para un
+binario que se EJECUTA, `radio` es un piso, no el número.** El sweep del store es el complemento.
+
+## 2 · Y lo que viajaba estaba a medias inerte: sin `pinentry` no hay clave con contraseña
+
+El script de prueba (`product-base-system-boot-test.sh`) corría `gpg --version` y daba verde. Batería
+completa contra los artefactos SELLADOS (gnupg `f610ba6a…`, gpg-agent propio, `GNUPGHOME` limpio):
+
+| prueba | resultado |
+|---|---|
+| `--version`, generar clave ed25519, listarla | ✅ |
+| firmar y verificar (`gpg --verify` **y** `gpgv` con su keyring) | ✅ |
+| cifrado simétrico y descifrado (sha256 del original recuperado) | ✅ |
+| cifrado asimétrico con subclave cv25519 y descifrado | ✅ |
+| firmar con clave **con contraseña** | ❌ `gpg: signing failed: No pinentry` |
+| `git commit -S` con esa misma clave | ❌ `fatal: failed to write commit object` |
+
+**`pinentry` no existía en el catálogo** (`ls recipes/ | grep pinentry` ⇒ nada), y gpg-agent lo busca
+por RUTA —`bindir/pinentry`, o sea `/usr/bin/pinentry`, comprobado contra el binario sellado— así que
+no hay configuración que lo salve. Y toda clave que un usuario trae de otra máquina tiene contraseña.
+
+⇒ **39,1 MiB en los 7 perfiles, sellados y reproducibles, con la operación más común rota.** Es
+exactamente la forma del bit setuid del paso 2: presente, íntegro, y sin poder hacer su trabajo.
+
+## 3 · La decisión: completarlo. `recipes/pinentry.toml`, 1,1 MiB
+
+`b3:18161b35d6cdcd0b0bbbea6bef14e86b7c10389aa37ed683436562d52041a451`, sellada, hash del dry-run
+idéntico al del sellado. `pinentry-curses` (777 K) + `pinentry-tty` (334 K), los dos estáticos musl, y
+el `/usr/bin/pinentry → pinentry-curses` que crea el `install-exec-local` de upstream. Frontends
+gráficas apagadas **explícitamente** una por una: casi todas tienen default `maybe` y auto-detectar es
+lo que mete una dep sin declararla. `libassuan` del corpus es API 3 y pinentry pide API 2 — **no hace
+falta parche**, su `m4/libassuan.m4:194` acepta `want=2 got=3`; comprobado antes de autorar.
+
+Con la receta sellada, y con un agente NUEVO (sin contraseña en caché):
+
+```
+gpg --detach-sign  con la clave protegida   ⇒ SALIDA=0   · firma verificada: Good signature
+git commit -S      con la clave protegida   ⇒ SALIDA=0   · [master 24061dd] · Good signature
+```
+
+Y la prueba que vale, porque no usa NADA del anfitrión: el mismo escenario dentro de un `bwrap` cuyo
+`/` es un rootfs hidratado **sólo** con artefactos del corpus (gnupg + pinentry + git + busybox). Ahí
+`/usr/bin/pinentry` es el del store, que es la ruta que gpg-agent tiene horneada.
+
+`pinentry` entra a `base` como RAÍZ —nadie lo alcanza por deps de build, es un hueco de runtime puro,
+igual que `bash-completion`— y con eso llega a los 7 perfiles. **Cero reconstrucciones**: `yupana radio
+pinentry` ⇒ 0 dependientes.
+
+## 4 · El guardián, medido en los dos sentidos
+
+La sonda vieja (`gpg --version`) no podía ver este defecto. La nueva, ya en
+`product-base-system-boot-test.sh`, firma con una clave protegida y **lee el error**, que es
+determinista y distingue las dos situaciones sin necesitar terminal (el test corre por ssh):
+
+```
+con pinentry   ⇒ gpg: signing failed: Not a tty      ← lo encontró y lo lanzó   ⇒ pinentry=ok
+sin pinentry   ⇒ gpg: signing failed: No pinentry    ← falta                    ⇒ pinentry=AUSENTE
+```
+
+Comprobado en los dos sentidos con el rootfs de bwrap y un control al que se le BORRAN los tres
+binarios de pinentry. La sonda es **condición de aprobado**, no una línea informativa más:
+`pinentry=ok` o el test falla. De paso, el contador `$hyd/26` pasa a derivarse de la lista — el 26
+escrito a mano envejecía a cada cambio y «25/26» se leía como fallo cuando el viejo era el contador
+([[umbral-a-mano-envejece]]).
+
+## 5 · La corrección al paso 2.3: sequoia no es el reemplazo, y medirlo cuesta cinco minutos
+
+El plan proponía `sequoia` (`sq`) como «el reemplazo moderno». **Con los consumidores medidos, no lo
+es**: ninguno quiere una librería OpenPGP ni una CLI nueva — `git` ejecuta lo que diga `gpg.program`
+hablando el protocolo de opciones y `--status-fd` de gpg, y `gcr` tiene la ruta `/usr/bin/gpg`
+horneada en el binario. Reemplazar significaría una CLI **compatible con gpg** (`gpg-sq` de sequoia),
+no `sq`. Sigue siendo posible y ahora tiene un criterio de aceptación concreto: pasar esta misma
+batería, incluida la sonda de pinentry. La alternativa de borrar a `gnupg` tiene hoy su factura
+nombrada: se lleva `git commit -S` en los 7 perfiles y el importador de claves de GNOME.
+
+⚠ **`git` tiene un segundo camino que acá no está disponible:** `gpg.format = ssh` firma con
+`ssh-keygen` desde git 2.34 — pero `openssh` sólo viaja en el perfil `servidor`, así que en `base`,
+`cli` y los 4 escritorios el único camino de firma es gpg.
+
+## 6 · Lo que queda dicho y NO resuelto
+
+- **`cap_ipc_lock`**: el `install-exec-local` de pinentry se la pone a sus binarios con `setcap`. Una
+  capability no puede viajar en el artefacto —igual que el setuid del paso 2, la pone el ensamblado— y
+  sin ella pinentry avisa «insecure memory» y funciona. Deuda nombrada. (En la receta el hook queda
+  como no-op limpio con `SETCAP=true`: nadie define `SETCAP`, ni `configure.ac` ni el `Makefile.in`, y
+  hoy se salvaba por un `|| true` tras ejecutar un comando inexistente.)
+- **Sin `dirmngr`** (apagado en `recipes/gnupg.toml`): no hay servidor de claves ni WKD. Medido:
+  `gpg --recv-keys` ⇒ `error running '/usr/bin/dirmngr': probably not installed`. Una clave ajena hay
+  que traerla por fichero.
+- **Sin `scdaemon`**: no hay tarjeta ni YubiKey. Medido: `gpg --card-status` ⇒ `OpenPGP card not
+  available: Not supported`.
+- **Sin `gpgtar`** (`--disable-gpgtar`), que es lo que `gpg --encrypt-files` usa para directorios.
+- ⚠ **El `BASE_SYSTEM` del boot-test sigue trayendo `sudo`, que SALIÓ de `base` en el paso 2**, y su
+  chequeo local exige `usr/bin/sudo`. Es la deriva de [[hidratar-desde-el-perfil-no-el-script]]: la
+  lista está copiada de `targets.toml` en vez de leída de ahí. No se barre de paso — cambia lo que el
+  test verifica—, pero queda anotado acá y no en la cabeza de nadie.
diff --git a/docs/state/targets.toml b/docs/state/targets.toml
index 0197ae5e..0333ac97 100644
--- a/docs/state/targets.toml
+++ b/docs/state/targets.toml
@@ -159,6 +159,14 @@ paquetes = [
   "iproute2", "iputils", "dhcpcd", "e2fsprogs", "dosfstools", "parted", "rsync",
   "sed", "tzdata", "vim", "dbus", "ca-certificates", "gnupg", "lsof", "strace",
   "pciutils", "usbutils", "mandoc",
+  # `pinentry` (2026-09-22, paso 9): el diálogo de contraseña que a `gnupg` le faltaba. Sin él, el
+  # gnupg que ya viajaba acá VERIFICA bien pero no puede usar NINGUNA clave con contraseña
+  # —`gpg: signing failed: No pinentry`—, o sea que `git commit -S` fallaba en la imagen con la
+  # única clase de clave que un usuario trae de otra máquina. Medido con los artefactos sellados:
+  # con pinentry, el mismo commit firmado sale 0. Entra como RAÍZ porque nadie lo alcanza por deps
+  # de build: gpg-agent lo busca por RUTA (`bindir/pinentry` ⇒ `/usr/bin/pinentry`) y lo ejecuta, que
+  # es un hueco de runtime puro — el mismo que `bash-completion` un poco más arriba. 1,1 MiB.
+  "pinentry",
   # `musl-shared` (2026-09-11): EL CARGADOR DINÁMICO. Va en `base` y no en un perfil concreto porque
   # sin él cualquier binario dinámico de la clausura queda INERTE — presente y sin poder ejecutarse.
   # Medido en la imagen del perfil `servidor`: **23 binarios de 726** pedían
diff --git a/recipes/pinentry.toml b/recipes/pinentry.toml
new file mode 100644
index 00000000..f9f1a1d7
--- /dev/null
+++ b/recipes/pinentry.toml
@@ -0,0 +1,78 @@
+# pinentry 1.3.3 — el diálogo de contraseña de GnuPG. Etapa G, hoja C.
+#
+# POR QUÉ ENTRA (medido 2026-09-22, paso 9 de `plan-que-sigue-tras-busybox.md`): sin pinentry, el
+# `gnupg` que ya viaja en los 7 perfiles VERIFICA bien pero no puede usar NINGUNA clave con
+# contraseña — `gpg: signing failed: No pinentry` —, y toda clave real tiene contraseña. O sea que
+# `git commit -S` (git está en los 7 perfiles) fallaba en la imagen con la única clase de clave que
+# un usuario trae de otra máquina. Es la misma forma del bit setuid del paso 2: 40 MiB sellados,
+# reproducibles y a medias inertes. gpg-agent busca su pinentry en `bindir/pinentry` — o sea
+# `/usr/bin/pinentry`, comprobado contra el binario sellado— y ese nombre lo publica esta receta.
+#
+# Sólo las dos frontends de terminal:
+#   --enable-pinentry-curses  → `pinentry-curses`, el que queda como `/usr/bin/pinentry` por el
+#                               `install-exec-local` de upstream (PINENTRY_DEFAULT, Makefile.am:128).
+#   --enable-pinentry-tty     → `pinentry-tty`, el que anda sin terminfo ni pantalla.
+#   --enable-fallback-curses  → sin frontend gráfica no cambia nada hoy, pero es lo que hace que una
+#                               futura GTK/Qt caiga a curses en vez de morir sin DISPLAY.
+# gtk2/gnome3/qt/qt4/qt5/tqt/fltk/efl/emacs van apagadas EXPLÍCITAMENTE, no por ausencia de deps:
+# `maybe` es el default de casi todas y auto-detectar es lo que mete una dep sin declararla.
+# `--disable-libsecret` por lo mismo: libsecret no está en el corpus y la caché de contraseñas en el
+# llavero es una decisión de producto, no un efecto secundario del configure.
+#
+# ⚠ libassuan del corpus es **API 3** (3.0.2) y pinentry pide API 2 — NO hace falta parche: su
+# `m4/libassuan.m4:194` acepta explícitamente `want=2 got=3`. Comprobado antes de autorar.
+#
+# ⚠ `SETCAP=true` en el install: el `install-exec-local` de upstream corre `$(SETCAP) cap_ipc_lock+p`
+# sobre cada binario y `SETCAP` no lo define NADIE (ni configure.ac ni el Makefile.in) ⇒ ejecuta
+# `cap_ipc_lock+p …`, que no existe, y se salva por el `|| true`. Con `SETCAP=true` es un no-op
+# limpio. La capability en sí no puede viajar en el artefacto —igual que el setuid del paso 2, la
+# pone el ensamblado— y sin ella pinentry avisa «insecure memory» pero funciona: deuda NOMBRADA,
+# no resuelta acá.
+name    = "pinentry"
+version = "1.3.3"
+license = "GPL-2.0-or-later"
+
+[source]
+tarball = "https://gnupg.org/ftp/gcrypt/pinentry/pinentry-1.3.3.tar.bz2"
+sha256  = "c2970f16d6afb66ecddfca767d743936c86239bff936eed7fd7597a678414b63"
+
+[build]
+compiler = "zig-cc"
+target   = "x86_64-linux-musl"
+link     = "static"
+
+# Split de la info de depuración (SDD 23 etapa 4, tanda de HOJAS). Entra en `hash_inputs`.
+strip_debug = true
+flags    = []
+
+[build.phases]
+configure = '''
+./configure \
+	--build=$CBUILD \
+	--host=$CHOST \
+	--prefix=/usr \
+	--sysconfdir=/etc \
+	--disable-nls \
+	--disable-doc \
+	--enable-pinentry-tty \
+	--enable-pinentry-curses \
+	--enable-fallback-curses \
+	--disable-pinentry-emacs \
+	--disable-inside-emacs \
+	--disable-pinentry-gtk2 \
+	--disable-pinentry-gnome3 \
+	--disable-pinentry-qt \
+	--disable-pinentry-qt4 \
+	--disable-pinentry-qt5 \
+	--disable-pinentry-tqt \
+	--disable-pinentry-fltk \
+	--disable-pinentry-efl \
+	--disable-libsecret \
+	--with-libgpg-error-prefix=/usr \
+	--with-libassuan-prefix=/usr
+'''
+compile = 'make'
+install = 'make DESTDIR=/out SETCAP=true install'
+
+[deps]
+build = ["libassuan", "libgpg-error", "ncurses", "linux-headers", "pkgconf", "make"]
diff --git a/scripts/product-base-system-boot-test.sh b/scripts/product-base-system-boot-test.sh
index 97fa7eb1..453cb8d1 100755
--- a/scripts/product-base-system-boot-test.sh
+++ b/scripts/product-base-system-boot-test.sh
@@ -1,7 +1,7 @@
 #!/usr/bin/env bash
 # product-base-system-boot-test.sh — E2E de la INTEGRACIÓN del userland foundational (base-system) al
 # producto: toma el product-rootfs sellado (arje-zero PID1 + sshd + userland Rust), le HIDRATA encima
-# los ~26 paquetes C del base-system (bash/git/sudo/util-linux/shadow/red/disco/TLS/gpg/proceso/editor)
+# los paquetes C del base-system (bash/git/sudo/util-linux/shadow/red/disco/TLS/gpg+pinentry/proceso/editor)
 # desde sus artefactos sellados del store, lo bootea en QEMU y verifica —DENTRO del guest booteado, por
 # SSH— que esos binarios están presentes y CORREN. Es la prueba de que "el sistema instalado los trae".
 #
@@ -27,7 +27,7 @@ rm -rf "$WORK"; mkdir -p "$WORK"
 cp -a "$PDIR"/. "$RFS"/; chmod -R u+w "$RFS"; rm -rf "$RFS/.hammer"
 
 # --- hidratar el base-system encima (no-clobber: preserva configs del producto) ---
-BASE_SYSTEM="bash git sudo doas util-linux shadow kbd procps-ng iproute2 iputils dhcpcd e2fsprogs dosfstools parted rsync sed tzdata vim dbus ca-certificates gnupg lsof strace pciutils usbutils mandoc"
+BASE_SYSTEM="bash git sudo doas util-linux shadow kbd procps-ng iproute2 iputils dhcpcd e2fsprogs dosfstools parted rsync sed tzdata vim dbus ca-certificates gnupg pinentry lsof strace pciutils usbutils mandoc"
 # Hidratación NO-CLOBBER: añade los binarios nuevos del base-system (bash/git/gpg/useradd/vim/…) pero
 # NO pisa nada del producto — ni configs (/etc/passwd, /etc/shadow, /etc/ssh) ni libs/applets. Preserva
 # el ABI musl del sshd del producto y su login (un clobber ciego de /usr/lib lo ROMPE: arje-zero+hammerd
@@ -47,7 +47,9 @@ for n in $BASE_SYSTEM; do
   done
   hyd=$((hyd+1))
 done
-echo "   $hyd/26 paquetes hidratados"
+# El total sale de la LISTA, no de un 26 escrito a mano: el número envejecía cada vez que la lista
+# cambiaba y entonces "25/26" se leía como fallo cuando era el contador el que estaba viejo.
+echo "   $hyd/$(set -- $BASE_SYSTEM; echo $#) paquetes hidratados"
 
 # provisión de admin (authorized_keys de prueba), igual que product-boot-test
 "$(ls store/*-openssh/usr/bin/ssh-keygen | head -1)" -t ed25519 -N '' -f "$WORK/clientkey" -q
@@ -56,7 +58,7 @@ chmod 0600 "$RFS/root/.ssh/authorized_keys"
 
 # sanity local: los binarios del base-system llegaron al árbol
 echo "==> chequeo local del árbol aumentado:"
-for f in bin/bash usr/bin/git usr/bin/sudo usr/sbin/useradd bin/ps sbin/ip usr/bin/gpg usr/bin/vim etc/ssl/certs/ca-certificates.crt; do
+for f in bin/bash usr/bin/git usr/bin/sudo usr/sbin/useradd bin/ps sbin/ip usr/bin/gpg usr/bin/pinentry usr/bin/vim etc/ssl/certs/ca-certificates.crt; do
   [ -e "$RFS/$f" ] && echo "   ok  $f" || { echo "   FALTA $f"; exit 1; }
 done
 # no clobbereamos el login del producto
@@ -81,6 +83,18 @@ while [ $(( $(date +%s) - start )) -lt "$DEADLINE" ]; do
       /bin/bash -c "echo bash=\$BASH_VERSION"
       echo "git=$(git --version)"
       echo "gpg=$(gpg --version | head -1)"
+      # SONDA DE PINENTRY (2026-09-22, paso 9). `gpg --version` contestaba bien con el gpg a medias
+      # inerte: sin pinentry, NINGUNA clave con contrasena se puede usar y `git commit -S` falla. La
+      # sonda firma con una clave protegida y mira el error: sin tty (esto corre por ssh) el veredicto
+      # es "Not a tty" si pinentry esta y se lanzo, y "No pinentry" si falta. Medido en los dos
+      # sentidos contra los artefactos sellados antes de escribirla.
+      export GNUPGHOME=/tmp/gpgprueba; rm -rf $GNUPGHOME; mkdir -p $GNUPGHOME; chmod 700 $GNUPGHOME
+      gpg --batch --pinentry-mode loopback --passphrase x --quick-gen-key "Prueba <p@takana.invalid>" ed25519 sign 0 >/dev/null 2>&1
+      sonda=$(gpg --batch --yes -u p@takana.invalid -o /dev/null --detach-sign /etc/passwd 2>&1 | head -1)
+      case "$sonda" in
+        *"No pinentry"*) echo "pinentry=AUSENTE ($sonda)" ;;
+        *) echo "pinentry=ok $(pinentry --version </dev/null 2>&1 | head -1) [$sonda]" ;;
+      esac
       echo "ps=$(ps --version 2>&1 | head -1)"
       echo "ip=$(ip -V 2>&1)"
       echo "useradd=$(useradd --help 2>&1 | head -1)"
@@ -89,7 +103,11 @@ while [ $(( $(date +%s) - start )) -lt "$DEADLINE" ]; do
       echo "certs=$(grep -c "BEGIN CERT" /etc/ssl/certs/ca-certificates.crt) CA"
       ' 2>/dev/null); then
     echo "============== GUEST BASE-SYSTEM =============="; echo "$out"; echo "=============================================="
-    echo "$out" | grep -q BASE_SYSTEM_OK && ok=1; break
+    echo "$out" | grep -q BASE_SYSTEM_OK && ok=1
+    # Un guardian que sólo mira el handshake deja pasar un binario presente y a medias inerte:
+    # la sonda de pinentry es condición de aprobado, no informativa.
+    echo "$out" | grep -q "pinentry=ok" || { echo "!! la sonda de pinentry NO aprobó"; ok=0; }
+    break
   fi
   sleep 3
 done