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…
⎇ 93c035f15f8f8aab331a99650e173b325ebf91b0
scripts/test-atuq-boveda-coherente.py
Historia
Quién tocó qué
crudo
el cabezal del guardián decía SIETE lugares y ya son ocho
1#!/usr/bin/env python3
2"""¿La bóveda está enchufada ENTERA, o a medias? (SDD-BOVEDA §8, D7)
3
4 python3 scripts/test-atuq-boveda-coherente.py
5 python3 scripts/test-atuq-boveda-coherente.py --negative-control
6 python3 scripts/test-atuq-boveda-coherente.py --negative-control-perfil
7 python3 scripts/test-atuq-boveda-coherente.py --negative-control-sembrador
8 python3 scripts/test-atuq-boveda-coherente.py --negative-control-deriva
9
10── QUÉ MIRA, Y POR QUÉ ESTE GUARDIÁN EXISTE APARTE ───────────────────────────────────────────
11Una extensión de `atuq` está enchufada en OCHO lugares distintos, y ninguno da error si falta:
12
13 manifest.json el id que la extensión declara
14 distribution/policies.json la política que la instala
15 native-messaging/*.json el permiso para hablarle al host
16 atuq.cfg las preferencias que la acompañan
17 recipes/puriy-costura.toml el COMMIT del host, que decide si sus verbos existen
18 docs/state/targets.toml quién DECLARA al dueño y al diálogo en las imágenes
19 recipes/agora-cli.toml quién puede SEMBRAR la identidad con la que la base se abre
20 recipes/boveda.toml y si lo pineado es lo de HOY, o una versión vieja que anda
21
22Si falta la política, la extensión no se instala y el usuario ve un navegador sin la función. Si
23falta el permiso del host, la extensión se instala y **se conecta a nada** —`connectNative` falla
24en silencio y la insignia no aparece nunca—. Si faltan las preferencias, la bóveda y el gestor de
25Gecko se pelean por el mismo campo. Y si el commit pineado del host es ANTERIOR a los verbos, la
26extensión conecta, manda `vault.match` y recibe «verbo desconocido» —que `fondo.js` lee como «no hay
27bóveda» y se calla—. Y si `boveda` y `shuma-pregunta` no están declaradas en el perfil, la
28extensión se instala, conecta, manda `vault.match` y el host contesta `{"ok":true,"locked":true}`
29—«cerrada», que es una respuesta EXITOSA— porque del otro lado del socket no hay nadie: el dueño
30está sellado y en ninguna imagen. Y si la imagen no trae un sembrador de identidad, el dueño SÍ
31está, abre su ventana, dice por qué no hay bóveda… y el navegador vuelve a ver `locked:true` —
32correcto y para siempre, porque nadie tiene con qué desbloquearla (§7.undecies). Y si la receta de
33la app está pineada DETRÁS de su propio paquete, todo lo anterior está en orden y lo que se ve es
34un comportamiento viejo: el §7.quaterdecies salió de una ventana que decía «se abre al entrar a la
35sesión» cuando el llavero era el que había fallado.
36Ninguno de los ocho se ve como un error: se ven como «no anda», o peor, como que anda.
37
38Éste NO reemplaza al guardián de metal (servidor HTTP, navegador real, login real, el diálogo de
39consentimiento a la vista). Lo precede: mide lo que se puede medir sin construir nada, en un
40segundo, y ahorra descubrir en metal un renglón que faltaba en un JSON.
41
42── LOS CINCO CONTROLES NEGATIVOS, UNO POR AFIRMACIÓN ─────────────────────────────────────────
43`--negative-control` saca el permiso del host de una copia en memoria y exige que esto lo detecte.
44`--negative-control-verbo` le manda al quinto chequeo un verbo inventado y exige lo mismo.
45`--negative-control-perfil` saca a `boveda` de los cuatro perfiles de escritorio, también en
46memoria, y exige que el sexto lo vea. `--negative-control-sembrador` hace lo mismo con `agora-cli`
47y exige que el séptimo lo vea. `--negative-control-deriva` finge el pin VIEJO de `boveda` —el que
48de verdad estuvo puesto hasta el 2026-09-22— y exige que el octavo lo vea atrasado; es el único de
49los cinco cuyo «mal» ya ocurrió. Sin ellos, un guardián que siempre dice que sí se vería
50idéntico a uno que funciona.
51
52Y el quinto chequeo trae además un control POSITIVO —`sct.observe`, que el host pineado tiene que
53tener sí o sí—, porque es un `grep` que espera cero y un cero puede ser una ausencia o un sitio
54equivocado: las dos se ven igual, y sólo las separa un patrón hermano que TENGA que dar positivo.
55"""
56import json
57import os
58import re
59import subprocess
60import sys
61import tomllib
62
63ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
64ATUQ = os.path.join(ROOT, "recipes/atuq")
65ID = "boveda@atuq.tawasuyu"
66
67# Las preferencias de D7: de fábrica manda la bóveda, pero el usuario puede volver atrás.
68PREFS = ["signon.rememberSignons", "signon.autofillForms"]
69
70# ── 8. EL QUINTO LUGAR: ¿el host que la RECETA PINEA sabe los verbos que ella manda? ──────────
71# Los siete de arriba miran `recipes/atuq/`, que es el lado del navegador. Pero una extensión no
72# está enchufada en cuatro lugares sino en CINCO, y el quinto vive en otra receta y en otro repo:
73# `recipes/puriy-costura.toml` pinea un commit de tawasuyu, y es ESE commit el que decide si
74# `vault.match` es un verbo o una cadena que nadie atiende.
75#
76# Falla igual de callado que los otros —peor, incluso—: el manifiesto está, el permiso está, la
77# política instala, `connectNative` CONECTA, la extensión manda `vault.match` y el host contesta
78# `{"ok": false, "error": "verbo desconocido: vault.match"}`. `fondo.js` lee `r.ok !== true`, borra
79# la insignia y se calla. Lo que el usuario ve es un navegador sin bóveda; lo que un guardián de
80# ficheros ve es todo en orden.
81#
82# Y no es hipotético: así estaba el 2026-09-15 al escribir esto. La extensión se commiteó con los
83# verbos ya escritos en tawasuyu (`bc6903f9e`) y la receta seguía pineada en `e19bb0e5`, que es su
84# ANCESTRO. El artefacto sellado lo confirma desde el otro lado, con su control puesto:
85# strings del binario sellado → «vault» 0 veces · «cas» 5 veces
86#
87# ── EL CONTROL POSITIVO NO ES DECORACIÓN ──────────────────────────────────────────────────────
88# Este chequeo es un `grep` que espera CERO, y un cero puede ser una ausencia o un sitio
89# equivocado: las dos se ven igual. Por eso se pregunta también por `sct.observe`, un verbo que el
90# commit pineado TIENE que tener —`sct` es la unidad 6, sellada y medida en metal—. Si el control
91# sale cero, lo que está roto es el guardián y se dice así, en vez de acusar a la receta.
92VERBOS_CONTROL = "sct.observe"
93TAWASUYU = os.environ.get("TAWASUYU", "/mnt/vvv/tawasuyu")
94RECETA_HOST = os.path.join(ROOT, "recipes/puriy-costura.toml")
95
96
97# Lo que parece un verbo y no lo es: los ficheros que el manifiesto nombra.
98NO_ES_VERBO = (".js", ".html", ".css", ".png", ".svg", ".json", ".txt", ".xpi", ".mjs")
99
100
101def verbos_que_manda_la_extension():
102 """Los verbos salen del propio código de la extensión, no de una lista escrita a mano acá.
103
104 Una lista paralela se desincroniza el día que alguien agrega un verbo y no toca este
105 fichero — y lo haría en el único sitio donde eso no puede verse.
106
107 ⚠ Y NO se busca `verb: "..."`, aunque la bóveda lo escriba así. Medido sobre las diez
108 extensiones: la de IA arma el mensaje con `postMessage({ id, verb: verbo, ... })`, o sea que
109 el verbo llega por variable y ese patrón no engancha NADA. Cero verbos encontrados y cero
110 verbos faltando se ven idénticos, y el guardián habría dicho que sí sin mirar nada. Por eso se
111 recogen todas las cadenas con forma de verbo y se descartan las que son nombres de fichero."""
112 fuentes = [f for f in sorted(os.listdir(os.path.join(ATUQ, "extensions/boveda")))
113 if f.endswith(".js")]
114 texto = "".join(leer(f"extensions/boveda/{f}") for f in fuentes)
115 crudos = set(re.findall(r'"([a-z][a-z0-9_]*\.[a-z][a-z0-9_]*)"', texto))
116 return sorted(v for v in crudos if not v.endswith(NO_ES_VERBO))
117
118
119def commit_pineado():
120 with open(RECETA_HOST, encoding="utf-8") as f:
121 m = re.search(r'^commit\s*=\s*"([0-9a-f]{7,40})"', f.read(), re.M)
122 return m.group(1) if m else None
123
124
125def atiende(commit, verbo):
126 """¿Hay un brazo de `match` para este verbo en el commit pineado?
127
128 `git grep <commit>` lee del objeto, así que no hace falta hacer checkout ni tocar el árbol
129 compartido de tawasuyu, que siempre tiene ficheros en vuelo de otras sesiones."""
130 r = subprocess.run(
131 ["git", "-C", TAWASUYU, "grep", "-qF", f'"{verbo}" =>', commit, "--", "*.rs"],
132 capture_output=True,
133 )
134 return r.returncode == 0
135
136
137def revisar_el_host(verbo_extra=None):
138 verbos = verbos_que_manda_la_extension()
139 if not verbos:
140 mal("no se pudo leer ningún `verb:` de fondo.js: este chequeo no está midiendo nada")
141 return
142 if verbo_extra:
143 verbos = verbos + [verbo_extra]
144
145 commit = commit_pineado()
146 if not commit:
147 mal(f"{os.path.relpath(RECETA_HOST, ROOT)} no pinea ningún commit")
148 return
149 if not os.path.isdir(os.path.join(TAWASUYU, ".git")):
150 # Un ausente falla RUIDOSAMENTE: si el clon no está, esto no se puede comprobar, y
151 # «no se pudo comprobar» no es «está bien».
152 mal(f"no hay clon de tawasuyu en {TAWASUYU}: el quinto lugar queda SIN COMPROBAR "
153 f"(se cambia con TAWASUYU=/ruta)")
154 return
155 if subprocess.run(["git", "-C", TAWASUYU, "cat-file", "-e", f"{commit}^{{commit}}"],
156 capture_output=True).returncode != 0:
157 mal(f"el clon de tawasuyu no conoce el commit pineado {commit[:9]}: "
158 f"hace falta un `git fetch` antes de creerle a este guardián")
159 return
160
161 # El control primero: si éste falla, ningún cero de abajo significa nada.
162 if not atiende(commit, VERBOS_CONTROL):
163 mal(f"CONTROL ROTO: {VERBOS_CONTROL} tampoco aparece en {commit[:9]}. El grep está mirando "
164 f"el sitio equivocado y los ceros de abajo no prueban ninguna ausencia")
165 return
166 bien(f"control: {VERBOS_CONTROL} SÍ está en el host pineado ({commit[:9]}) ⇒ el grep mide")
167
168 for v in verbos:
169 if atiende(commit, v):
170 bien(f"el host pineado atiende {v}")
171 else:
172 mal(f"el host pineado ({commit[:9]}) NO atiende {v}: la extensión lo manda y recibe "
173 f"«verbo desconocido», que fondo.js lee como «no hay bóveda» y se calla")
174
175# ── 9. EL SEXTO LUGAR: ¿alguien DECLARA al dueño y al diálogo en las imágenes? ───────────────
176# El quinto lugar pregunta si el host SABE los verbos. Éste pregunta lo de una capa más abajo, que
177# es lo que costó tres semanas de no ver (SDD 26 §7.sexies): quién CONTESTA del otro lado del
178# socket. El host no abre la bóveda nunca, a propósito —`sled` toma un lock exclusivo y el proceso
179# que lanza Gecko muere con cada pestaña—, así que le habla a un DUEÑO; y el 2026-09-18 el dueño
180# estaba SELLADO y en `perfiles: []`, o sea en ninguna imagen. Sellado ≠ instalado.
181#
182# Se mira `targets.toml` —el manifiesto de objetivo— y no `build-state.json`, que es su derivado:
183# lo que decide qué va en la imagen es la declaración, y el estado se recalcula solo.
184PERFILES_ESCRITORIO = ["escritorio-kde", "escritorio-gnome", "escritorio-cosmic", "escritorio-sway"]
185# Las DOS o ninguna: media bóveda es una que niega todo en silencio. Sin el dueño, `vault.match` no
186# ofrece nada; sin el diálogo, `Command::new` falla y TODO `vault.fill` se deniega.
187LA_BOVEDA_EN_LA_IMAGEN = ["boveda", "shuma-pregunta"]
188# El control POSITIVO, por lo mismo que el del §quinto lugar: un `in` que espera encontrar puede
189# fallar porque la raíz falta o porque estoy leyendo el campo equivocado, y las dos se ven igual.
190# `atuq` TIENE que estar en los cuatro — si no está, este chequeo no está midiendo el fichero.
191RAIZ_CONTROL = "atuq"
192TARGETS = os.path.join(ROOT, "docs/state/targets.toml")
193
194
195def revisar_los_perfiles(sin_esta=None):
196 try:
197 with open(TARGETS, "rb") as f:
198 perfiles = tomllib.load(f)["perfil"]
199 except FileNotFoundError:
200 mal(f"no existe {os.path.relpath(TARGETS, ROOT)}: el sexto lugar queda SIN COMPROBAR")
201 return
202 except Exception as e: # TOML roto es un fallo ruidoso, no un «está bien»
203 mal(f"no se pudo leer {os.path.relpath(TARGETS, ROOT)} ({e})")
204 return
205
206 for perfil in PERFILES_ESCRITORIO:
207 if perfil not in perfiles:
208 mal(f"{perfil} no existe en targets.toml: ¿se renombró un perfil de escritorio?")
209 continue
210 raices = list(perfiles[perfil].get("paquetes", []))
211 if sin_esta:
212 raices = [r for r in raices if r != sin_esta]
213 if RAIZ_CONTROL not in raices:
214 mal(f"CONTROL ROTO: {perfil} no declara {RAIZ_CONTROL}. O el navegador se cayó de la "
215 f"imagen, o estoy leyendo el campo equivocado — y entonces lo de abajo no prueba nada")
216 continue
217 faltan = [r for r in LA_BOVEDA_EN_LA_IMAGEN if r not in raices]
218 if faltan:
219 mal(f"{perfil} NO declara {', '.join(faltan)}: la extensión se instala igual y la "
220 f"bóveda contesta `locked:true` con `ok:true` — apagada, y sin un solo error")
221 else:
222 bien(f"{perfil} declara el dueño y el diálogo ({RAIZ_CONTROL} de control)")
223
224
225# ── 10. EL SÉPTIMO LUGAR: ¿hay en la imagen alguien capaz de SEMBRAR la identidad? ────────────
226# El sexto lugar pregunta si el DUEÑO de la base está en la imagen. Éste pregunta lo que queda
227# debajo y es lo que costó el §7.undecies: el dueño abre la bóveda con una clave derivada de la
228# seed de identidad, que saca del llavero de sesión (`pacha_llavero::SEED_IDENTIDAD`). Si nadie
229# la sembró, `abrir()` da `Err(boveda-cerrada)` SIEMPRE — no «todavía no la desbloqueaste»: nunca.
230#
231# Y el 2026-09-21, en las cuatro imágenes, no había con qué: en todo tawasuyu sólo DOS binarios
232# escriben esa clave —`agora-cli identity unlock` y el onboarding `churay-welcome-runner`—, y
233# `agora-cli` estaba sellado con `perfiles: []` (en ninguna imagen) y además pineado a un commit
234# de junio donde el verbo todavía no existía, mientras que `churay-welcome` no tiene receta.
235#
236# Se comprueba en los dos planos, porque fallan distinto y los dos en silencio:
237# a) el COMMIT que la receta pinea sabe sembrar (si no: «unknown subcommand», o peor, un binario
238# que hace otra cosa con el mismo nombre);
239# b) algún perfil de escritorio lo DECLARA (si no: sellado ≠ instalado, la lección de `foot`).
240SEMBRADORES = {"agora-cli": os.path.join(ROOT, "recipes/agora-cli.toml")}
241# Lo que un sembrador TIENE que hacer, leído del commit pineado y no de la memoria: guardar la
242# seed bajo la constante canónica. Un `identity unlock` que no llame a esto no siembra nada.
243SIEMBRA = "guardar(pacha_llavero::SEED_IDENTIDAD"
244# El control POSITIVO del grep, por lo mismo de siempre: este chequeo espera ENCONTRAR, y un
245# «no encontré» puede ser una ausencia o un patrón equivocado. La constante se define en
246# `pacha-llavero` y tiene que estar en cualquier commit del monorepo.
247SIEMBRA_CONTROL = 'SEED_IDENTIDAD: &str'
248
249
250def pin_de(receta):
251 with open(receta, encoding="utf-8") as f:
252 m = re.search(r'^commit\s*=\s*"([0-9a-f]{7,40})"', f.read(), re.M)
253 return m.group(1) if m else None
254
255
256def hay_en(commit, patron):
257 r = subprocess.run(
258 ["git", "-C", TAWASUYU, "grep", "-qF", patron, commit, "--", "*.rs"],
259 capture_output=True,
260 )
261 return r.returncode == 0
262
263
264def revisar_el_sembrador(sin_sembrador=None):
265 if not os.path.isdir(os.path.join(TAWASUYU, ".git")):
266 mal(f"no hay clon de tawasuyu en {TAWASUYU}: el séptimo lugar queda SIN COMPROBAR "
267 f"(se cambia con TAWASUYU=/ruta)")
268 return
269
270 # a) el commit pineado sabe sembrar
271 for nombre, receta in SEMBRADORES.items():
272 if not os.path.exists(receta):
273 mal(f"no existe {os.path.relpath(receta, ROOT)}: {nombre} no está en el corpus")
274 continue
275 commit = pin_de(receta)
276 if not commit:
277 mal(f"{os.path.relpath(receta, ROOT)} no pinea ningún commit")
278 continue
279 if subprocess.run(["git", "-C", TAWASUYU, "cat-file", "-e", f"{commit}^{{commit}}"],
280 capture_output=True).returncode != 0:
281 mal(f"el clon de tawasuyu no conoce el pin {commit[:9]} de {nombre}: "
282 f"hace falta un `git fetch` antes de creerle a este guardián")
283 continue
284 if not hay_en(commit, SIEMBRA_CONTROL):
285 mal(f"CONTROL ROTO: {SIEMBRA_CONTROL!r} tampoco aparece en {commit[:9]}. El grep mira "
286 f"el sitio equivocado y su «no está» no prueba nada")
287 continue
288 if hay_en(commit, SIEMBRA):
289 bien(f"{nombre} pineado ({commit[:9]}) SÍ siembra {SIEMBRA[:-1]}…)")
290 else:
291 mal(f"{nombre} pineado ({commit[:9]}) NO siembra la seed de identidad: la bóveda "
292 f"contesta `boveda-cerrada` siempre y nadie tiene con qué abrirla")
293
294 # b) alguien lo declara en cada perfil de escritorio
295 try:
296 with open(TARGETS, "rb") as f:
297 perfiles = tomllib.load(f)["perfil"]
298 except Exception as e:
299 mal(f"no se pudo leer {os.path.relpath(TARGETS, ROOT)} ({e})")
300 return
301 for perfil in PERFILES_ESCRITORIO:
302 raices = list(perfiles.get(perfil, {}).get("paquetes", []))
303 if sin_sembrador:
304 raices = [r for r in raices if r != sin_sembrador]
305 if RAIZ_CONTROL not in raices:
306 mal(f"CONTROL ROTO: {perfil} no declara {RAIZ_CONTROL}, así que lo de abajo no mide")
307 continue
308 presentes = [n for n in SEMBRADORES if n in raices]
309 if presentes:
310 bien(f"{perfil} trae un sembrador de identidad ({', '.join(presentes)})")
311 else:
312 mal(f"{perfil} NO trae ningún sembrador de identidad ({'/'.join(SEMBRADORES)}): la "
313 f"bóveda está declarada y NADIE puede abrirla — `locked:true` para siempre")
314
315
316# ── 11. EL OCTAVO LUGAR: ¿la receta está pineada DETRÁS de su propio paquete? ─────────────────
317# Los siete de arriba preguntan si cada pieza ESTÁ. Éste pregunta si la pieza que está es la de
318# HOY, y es la forma de fallo que costó el §7.terdecies: `boveda` se quedó en `6f0408d40` mientras
319# el arreglo de `pacha-boveda-llimphi` se publicaba en `4f2b5eac7`. Nada falló. El artefacto sella,
320# reproduce, entra a las cuatro imágenes, abre su ventana — y esa ventana dice «La bóveda está
321# cerrada. Se abre al entrar a la sesión», que es verdad si no hay seed sembrada y MENTIRA si el
322# llavero falló. Un mensaje equivocado no tiene código de salida.
323#
324# Es el mismo cuerpo del §7.quinquies —el cable tiene dos puntas en dos repos y se desincronizan en
325# silencio— pero un piso más abajo: allá la punta que se quedaba atrás era el HOST y el síntoma era
326# «verbo desconocido»; acá es la receta de la app y el síntoma es un texto.
327#
328# La pregunta se hace en la forma que NO envejece: no se busca un símbolo (`SeedDeSesion` se puede
329# renombrar y entonces el guardián miente en verde), se le pregunta a git si hay commits en `main`
330# que TOQUEN el paquete que la receta construye y que el pin no tenga. Cero commits ⇒ la receta
331# está al día. N commits ⇒ se nombran, porque «estás atrás» sin decir de qué no sirve para decidir.
332#
333# ⚠ Esto NO dice que haya que subir el pin siempre: un pin viejo puede ser deliberado. Dice que la
334# distancia EXISTE y cuál es, que es justo lo que nadie sabía el 2026-09-21.
335#
336# El control POSITIVO es que el pin sea ancestro de `main`: si no lo es, el `pin..main` da cero por
337# una razón que no es «al día» —un pin de otra rama, un clon sin fetch— y el verde sería falso.
338PAQUETES_DE = {
339 "recipes/boveda.toml": [
340 "shared/pacha/pacha-boveda-llimphi", # la app: su ventana es la que dio el mensaje falso
341 "shared/pacha/pacha-llavero", # el crate compartido donde vivía la causa
342 ],
343 "recipes/shuma-pregunta.toml": ["02_ruway/shuma/shuma-pregunta"],
344}
345
346
347def commits_por_delante(commit, ruta):
348 r = subprocess.run(
349 ["git", "-C", TAWASUYU, "log", "--oneline", f"{commit}..main", "--", ruta],
350 capture_output=True, text=True,
351 )
352 return [l for l in r.stdout.splitlines() if l.strip()]
353
354
355def revisar_la_deriva(pin_forzado=None):
356 if not os.path.isdir(os.path.join(TAWASUYU, ".git")):
357 mal(f"no hay clon de tawasuyu en {TAWASUYU}: el octavo lugar queda SIN COMPROBAR "
358 f"(se cambia con TAWASUYU=/ruta)")
359 return
360 for rel, rutas in PAQUETES_DE.items():
361 receta = os.path.join(ROOT, rel)
362 commit = pin_forzado or pin_de(receta)
363 if not commit:
364 mal(f"{rel} no pinea ningún commit")
365 continue
366 if subprocess.run(["git", "-C", TAWASUYU, "merge-base", "--is-ancestor", commit, "main"],
367 capture_output=True).returncode != 0:
368 mal(f"CONTROL ROTO: el pin {commit[:9]} de {rel} no es ancestro de `main` en el clon "
369 f"({TAWASUYU}) — puede faltar un `git fetch`, o ser otra rama. Un «cero commits "
370 f"por delante» calculado así no prueba nada")
371 continue
372 atrasados = []
373 for ruta in rutas:
374 atrasados += [(ruta, c) for c in commits_por_delante(commit, ruta)]
375 if not atrasados:
376 bien(f"{os.path.basename(rel)} pineada ({commit[:9]}) está al día con "
377 f"{', '.join(os.path.basename(r) for r in rutas)}")
378 else:
379 mal(f"{os.path.basename(rel)} pineada ({commit[:9]}) está DETRÁS de "
380 f"{len(atrasados)} commit(s) que tocan su propio paquete — el artefacto sella y "
381 f"reproduce igual, y lo que se ve es un comportamiento viejo sin un solo error:")
382 for ruta, c in atrasados:
383 print(f" · {os.path.basename(ruta)}: {c}")
384
385
386fallas = []
387
388
389def mal(msg):
390 fallas.append(msg)
391 print(f" ✗ {msg}")
392
393
394def bien(msg):
395 print(f" ✓ {msg}")
396
397
398def leer(p):
399 with open(os.path.join(ATUQ, p), encoding="utf-8") as f:
400 return f.read()
401
402
403def revisar(permisos_host, verbo_extra=None, sin_perfil=None, sin_sembrador=None,
404 pin_deriva=None):
405 # 1. La extensión existe y declara el id que todos los demás nombran.
406 try:
407 m = json.loads(leer("extensions/boveda/manifest.json"))
408 except FileNotFoundError:
409 mal("no existe extensions/boveda/manifest.json")
410 return
411 declarado = m.get("browser_specific_settings", {}).get("gecko", {}).get("id")
412 if declarado != ID:
413 mal(f"el manifest declara {declarado!r} y el resto nombra {ID!r}")
414 else:
415 bien(f"la extensión declara {ID}")
416
417 # 2. Los permisos que necesita para poder hacer su trabajo.
418 for p in ("nativeMessaging", "tabs"):
419 if p not in m.get("permissions", []):
420 mal(f"le falta el permiso {p!r}: sin eso no puede {'hablarle al host' if p == 'nativeMessaging' else 'saber qué página mira el usuario'}")
421 if not m.get("content_scripts"):
422 mal("sin content script no puede rellenar ningún campo")
423 if "background" not in m:
424 mal("sin fondo no hay quién hable con el host")
425
426 # 3. Los archivos que el manifest promete.
427 for rel in [m.get("background", {}).get("scripts", []), *[c.get("js", []) for c in m.get("content_scripts", [])]]:
428 for f in rel:
429 if not os.path.exists(os.path.join(ATUQ, "extensions/boveda", f)):
430 mal(f"el manifest nombra {f} y el archivo no está")
431
432 # 4. La política que la instala.
433 pol = json.loads(leer("distribution/policies.json"))
434 ext = pol.get("policies", {}).get("ExtensionSettings", {})
435 if ID not in ext:
436 mal("no está en ExtensionSettings: el navegador no la va a instalar")
437 elif ext[ID].get("installation_mode") != "normal_installed":
438 mal(f"se instala en modo {ext[ID].get('installation_mode')!r}, que no la deja activada")
439 else:
440 bien("la política la instala")
441
442 # 5. El permiso para hablarle al host. Éste es el que falla en SILENCIO.
443 if ID not in permisos_host:
444 mal("no está en allowed_extensions del host: se instala y no se conecta a nada")
445 else:
446 bien("el host la deja hablar")
447
448 # 6. Las preferencias de D7, y que sean `defaultPref` y no `lockPref`.
449 cfg = leer("atuq.cfg")
450 for p in PREFS:
451 if not re.search(rf'defaultPref\("{re.escape(p)}"', cfg):
452 mal(f"falta defaultPref({p!r}): el gestor de Gecko va a pelear por el mismo campo")
453 if re.search(rf'lockPref\("{re.escape(p)}"', cfg):
454 mal(f"{p} está como lockPref: eso decide POR el usuario, y atuq no hace eso")
455 if not fallas:
456 bien("las preferencias están, y como valores de arranque y no como cárcel")
457
458 # 7. Y lo que la extensión NO tiene que hacer: decidir ella qué credencial va.
459 fondo = leer("extensions/boveda/fondo.js")
460 if "sender.tab.url" not in fondo:
461 mal("el fondo no toma la dirección del chrome: una página podría decir que es el banco")
462 else:
463 bien("la dirección la pone el navegador, no la página")
464
465 # 8. Y el quinto lugar, que vive en otra receta y en otro repo.
466 revisar_el_host(verbo_extra)
467
468 # 9. Y el sexto, que vive en el manifiesto de objetivo: quién la pone en la imagen.
469 revisar_los_perfiles(sin_perfil)
470
471 # 10. Y el séptimo: quién puede sembrar la identidad con la que esa bóveda se abre.
472 revisar_el_sembrador(sin_sembrador)
473
474 # 11. Y el octavo: si lo que está pineado es lo de hoy, o una versión vieja que no falla.
475 revisar_la_deriva(pin_deriva)
476
477
478def main():
479 control = "--negative-control" in sys.argv
480 control_verbo = "--negative-control-verbo" in sys.argv
481 control_perfil = "--negative-control-perfil" in sys.argv
482 control_sembrador = "--negative-control-sembrador" in sys.argv
483 control_deriva = "--negative-control-deriva" in sys.argv
484 host = json.loads(leer("native-messaging/puriy_costura.json"))
485 permisos = list(host.get("allowed_extensions", []))
486 verbo_extra = None
487 if control:
488 print("⚠ control negativo: se le saca el permiso del host")
489 permisos = [x for x in permisos if x != ID]
490 if control_verbo:
491 # Un verbo que ningún commit puede atender: si el quinto chequeo no lo ve faltar, no está
492 # mirando el host sino contestando que sí.
493 verbo_extra = "vault.este_verbo_no_existe"
494 print(f"⚠ control negativo: se le agrega el verbo inventado {verbo_extra}")
495 sin_perfil = None
496 if control_perfil:
497 # El dueño, sacado de una copia en memoria de los cuatro perfiles: si el sexto chequeo no
498 # lo ve faltar, está contestando que sí en vez de mirar `targets.toml`.
499 sin_perfil = "boveda"
500 print(f"⚠ control negativo: se saca {sin_perfil} de los perfiles de escritorio")
501 sin_sembrador = None
502 if control_sembrador:
503 # El sembrador, sacado de la misma copia en memoria: si el séptimo chequeo no lo ve
504 # faltar, está contestando que sí en vez de mirar quién puede abrir la bóveda.
505 sin_sembrador = "agora-cli"
506 print(f"⚠ control negativo: se saca {sin_sembrador} de los perfiles de escritorio")
507
508 pin_deriva = None
509 if control_deriva:
510 # El pin ANTERIOR, el que de verdad estuvo puesto hasta el 2026-09-22 y que se quedó atrás
511 # del arreglo del llavero: si el octavo chequeo no lo ve atrasado, no está preguntándole a
512 # git — está contestando que sí. Es el único de los cinco controles cuyo «mal» ya ocurrió.
513 pin_deriva = "6f0408d40d05d7961e811f9062ce5d5127987635"
514 print(f"⚠ control negativo: se finge el pin viejo {pin_deriva[:9]} (el del §7.undecies)")
515
516 print(f"── la bóveda en atuq ──")
517 revisar(permisos, verbo_extra, sin_perfil, sin_sembrador, pin_deriva)
518
519 if control:
520 if any("allowed_extensions" in f for f in fallas):
521 print("\n✓ el control negativo lo detectó")
522 return 0
523 print("\n✗ el control negativo NO lo detectó: este guardián no mide nada")
524 return 1
525 if control_verbo:
526 if any(verbo_extra in f for f in fallas):
527 print("\n✓ el control negativo del verbo lo detectó")
528 return 0
529 print("\n✗ el control del verbo NO lo detectó: el quinto chequeo no mide nada")
530 return 1
531 if control_perfil:
532 if any(sin_perfil in f and "NO declara" in f for f in fallas):
533 print("\n✓ el control negativo del perfil lo detectó")
534 return 0
535 print("\n✗ el control del perfil NO lo detectó: el sexto chequeo no mide nada")
536 return 1
537 if control_sembrador:
538 if any("sembrador de identidad" in f and "NO trae" in f for f in fallas):
539 print("\n✓ el control negativo del sembrador lo detectó")
540 return 0
541 print("\n✗ el control del sembrador NO lo detectó: el séptimo chequeo no mide nada")
542 return 1
543 if control_deriva:
544 if any("está DETRÁS de" in f for f in fallas):
545 print("\n✓ el control negativo de la deriva lo detectó")
546 return 0
547 print("\n✗ el control de la deriva NO lo detectó: el octavo chequeo no mide nada")
548 return 1
549 if fallas:
550 print(f"\n✗ {len(fallas)} problema(s)")
551 return 1
552 print("\n✓ la bóveda está enchufada entera")
553 return 0
554
555
556if __name__ == "__main__":
557 raise SystemExit(main())