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…

TOML 1471Shell 173Rust 105Markdown 943545 Definiciones

⎇ 26c153c149c81663140768bfda7dafb19c010e4f scripts/test-atuq-boveda-coherente.py Python · 461 líneas · 23.6 KB Historia Quién tocó qué crudo

atuq §7.duodecies: el sembrador entra a las cuatro imágenes — y cifraba la seed de todos con una palabra pública c7097e4a9 · Sergio ·

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