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 1572Shell 203Rust 109Markdown 993728 Sutichaykuna

⎇ 2f9253886ff843d9701ac61a9c3ff4f2cd43ef33 scripts/test-atuq-sct.py Python · 341 siq'ikuna · 17.5 KB Ñawpa willay Pi imata llamkarqan ch'uya

atuq: `sct` v1 — el navegador avisa cuando un sitio ya estable ejecuta código que nadie vio nunca 02513b861 · Sergio ·

1#!/usr/bin/env python3
2"""¿`sct` avisa cuando un sitio ya estable ejecuta código que nadie vio nunca? (SDD 26 §6.1)
3
4    python3 scripts/test-atuq-sct.py                     # la cadena entera, seis cargas de página
5    python3 scripts/test-atuq-sct.py --negative-control   # sin el manifiesto: exige que NO haya veredicto
6
7── QUÉ CADENA SE EJERCITA, Y POR QUÉ NINGUNA PIEZA ALCANZA SOLA ──────────────────────────────
8Un servidor HTTP de verdad sirve una página con un `<script src>`; el navegador la carga seis veces;
9la extensión `sct@atuq.tawasuyu` intercepta el cuerpo del script con `filterResponseData`, se lo pasa
10al host nativo por native messaging, y el host —`puriy-costura`, que por dentro es `puriy-sct`—
11hashea con BLAKE3, aprende el conjunto estable del origen y contesta. Son SEIS piezas en cadena:
12
13    servidor HTTP → filterResponseData → connectNative → manifiesto de /usr/lib/mozilla
14                  → puriy-costura --state → puriy-sct (TOFU + bitácora)
15
16Y el veredicto no se deduce de ninguna de ellas: sale del **motor**, por el `dump()` de la extensión,
17que es lo que el navegador de verdad va a mostrar en la insignia.
18
19── LAS TRES AFIRMACIONES, Y LAS DOS QUE SON CONTROLES ────────────────────────────────────────
20    cargas 1-3   con el MISMO script          ⇒ fase `learning`, CERO eventos
21    carga  4     con el MISMO script          ⇒ fase `stable` (racha 3 de la Config por defecto)
22    carga  5     con el MISMO script, YA estable ⇒ CERO eventos   ← control: no alerta por cargar
23    carga  6     con el script CAMBIADO       ⇒ UN evento, con el hash viejo, el nuevo y el delta
24
25Las cargas 5 y 6 son el par que hace que la prueba signifique algo: sin la 5, un detector que
26alertara en cada carga se vería idéntico a uno que funciona. Y `--negative-control` va más al hueso:
27borra el manifiesto de `/usr/lib/mozilla/native-messaging-hosts/` y exige que NO haya veredicto —o
28sea que el veredicto de la corrida positiva viene del host y no de la extensión inventándolo—.
29
30── Y EL ESTADO QUEDA EN DISCO, QUE ES LO QUE HACE QUE ESTO PROTEJA ───────────────────────────
31El perfil y el `$HOME` se reusan entre las seis corridas, así que el navegador se ABRE Y SE CIERRA
32seis veces: si el host no persistiera, cada arranque volvería a `learning` y la carga 6 no alertaría
33nunca. Al final se le pregunta al propio binario del host, desde afuera, por el estado del origen.
34
35REQUISITOS: el rootfs de `scripts/atuq-nested.sh`, y `atuq` + `puriy-costura` sellados.
36"""
37import http.server
38import json
39import os
40import shutil
41import socket
42import struct
43import subprocess
44import sys
45import tempfile
46import threading
47
48ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
49RFS = os.environ.get("RFS", "/mnt/cosecha/escritorios/atuq-rootfs")
50ALPINE = os.path.join(ROOT, ".dev-fs/alpine")
51HAMMER = os.path.join(ROOT, "target/release/takana")
52STORE = os.environ.get("HAMMER_STORE", os.path.join(ROOT, "store"))
53MANIFIESTO = "/usr/lib/mozilla/native-messaging-hosts/puriy_costura.json"
54
55SCRIPT_ORIGINAL = "window.__atuq_sct = 1;\n"
56SCRIPT_CAMBIADO = "window.__atuq_sct = 1; /* esto no lo vio nadie */\n"
57PAGINA = """<!doctype html><html><head><meta charset="utf-8"><title>sct</title>
58<script src="/app.js"></script></head><body>sct</body></html>
59"""
60
61
62def fatal(msg):
63    print(f"✗ {msg}", file=sys.stderr)
64    raise SystemExit(1)
65
66
67def artefacto(receta, nombre, escotilla=None):
68    """El artefacto VIGENTE, por `takana hash` y nunca por glob.
69
70    ⚠ La escotilla (`ATUQ_DIR`) grita: el corpus es compartido y otro frente puede re-hashear
71    `firefox`, dejando el atuq vigente sin sellar durante las horas de su build. Un resultado
72    obtenido con la escotilla NO se cita como «el atuq de hoy pasa».
73    """
74    forzado = os.environ.get(escotilla) if escotilla else None
75    if forzado:
76        if not os.path.isdir(forzado):
77            fatal(f"{escotilla}={forzado} no existe")
78        print(f"⚠ {escotilla}: se mide {os.path.basename(forzado)}, que NO es necesariamente el vigente")
79        return forzado
80    r = subprocess.run([HAMMER, "--store", STORE, "hash", f"recipes/{receta}.toml"],
81                       cwd=ROOT, capture_output=True, text=True)
82    if r.returncode != 0:
83        fatal(f"no pude hashear {receta}: {r.stderr.strip()}")
84    h = r.stdout.strip().splitlines()[-1].removeprefix("b3:")
85    d = os.path.join(STORE, f"{h}-{nombre}")
86    if not os.path.isdir(d):
87        fatal(f"{nombre} b3:{h[:8]} no está sellado — construilo antes")
88    return d
89
90
91class Silencioso(http.server.SimpleHTTPRequestHandler):
92    """Sirve la página y el script desde memoria. El cuerpo del script lo cambia el test entre
93    cargas, que es justo lo que `sct` tiene que notar."""
94
95    cuerpo_script = SCRIPT_ORIGINAL
96
97    def log_message(self, *a):
98        pass
99
100    def do_GET(self):
101        if self.path.startswith("/app.js"):
102            datos, tipo = Silencioso.cuerpo_script.encode(), "application/javascript"
103        else:
104            datos, tipo = PAGINA.encode(), "text/html"
105        self.send_response(200)
106        self.send_header("Content-Type", tipo)
107        self.send_header("Content-Length", str(len(datos)))
108        # Sin caché: si el navegador reusa el script de su caché, la carga 6 no vería el cambio y la
109        # prueba diría «no alerta» por una razón que no tiene nada que ver con sct.
110        self.send_header("Cache-Control", "no-store, no-cache, must-revalidate")
111        self.end_headers()
112        self.wfile.write(datos)
113
114
115def puerto_libre():
116    s = socket.socket()
117    s.bind(("127.0.0.1", 0))
118    p = s.getsockname()[1]
119    s.close()
120    return p
121
122
123def correr(atuq, costura, salida, capa, url, negativo):
124    """Una carga de página: abre atuq headless con la URL y devuelve las líneas `SCT`."""
125    borrar = f"rm -f {MANIFIESTO}" if negativo else "true"
126    guion = f"""
127    mkdir -p /root /salida/hogar
128    [ -s /etc/machine-id ] || tr -d - < /proc/sys/kernel/random/uuid > /etc/machine-id
129    {borrar}
130    export HOME=/salida/hogar
131    timeout 40 /usr/bin/atuq --profile /salida/perfil --no-remote --headless '{url}' \
132        > /salida/consola.log 2>&1
133    exit 0
134    """
135    subprocess.run([
136        "bwrap",
137        "--overlay-src", ALPINE, "--overlay-src", RFS,
138        "--overlay-src", atuq, "--overlay-src", costura, "--overlay-src", capa,
139        "--tmp-overlay", "/",
140        "--dev-bind", "/dev", "/dev", "--proc", "/proc", "--bind", "/sys", "/sys",
141        "--tmpfs", "/tmp", "--tmpfs", "/run", "--unshare-pid",
142        "--bind", salida, "/salida",
143        "--uid", "0", "--gid", "0",
144        "--setenv", "PATH", "/usr/bin:/bin:/usr/sbin:/sbin",
145        "--setenv", "XDG_RUNTIME_DIR", "/run",
146        "--setenv", "XDG_DATA_DIRS", "/usr/share", "--setenv", "MOZ_HEADLESS", "1",
147        "--setenv", "MOZ_DISABLE_CONTENT_SANDBOX", "1",
148        "--setenv", "MOZ_DISABLE_GMP_SANDBOX", "1",
149        "--setenv", "MOZ_DISABLE_RDD_SANDBOX", "1",
150        "--setenv", "MOZ_DISABLE_SOCKET_PROCESS_SANDBOX", "1",
151        "--setenv", "MOZ_DISABLE_UTILITY_SANDBOX", "1",
152        "sh", "-c", guion,
153    ], check=False)
154    log = open(os.path.join(salida, "consola.log"), errors="replace").read()
155    return [l[len("SCT "):] for l in log.splitlines() if l.startswith("SCT ")], log
156
157
158def preguntarle_al_host(costura, estado):
159    """Le pregunta al binario del host, DESDE AFUERA de la jaula, por el estado del origen. Es la
160    evidencia de que lo que el navegador aprendió quedó en disco y no en la memoria de un proceso."""
161    binario = os.path.join(costura, "usr/bin/puriy-costura")
162    peticion = json.dumps({"verb": "sct.state", "url": "http://127.0.0.1/"}).encode()
163    entrada = struct.pack("<I", len(peticion)) + peticion
164    r = subprocess.run([binario, "--state", estado], input=entrada, capture_output=True, timeout=60)
165    if len(r.stdout) < 4:
166        return None
167    n = struct.unpack("<I", r.stdout[:4])[0]
168    return json.loads(r.stdout[4:4 + n])
169
170
171def main():
172    negativo = "--negative-control" in sys.argv[1:]
173    if negativo:
174        print("== MODO CONTROL NEGATIVO: se borra el manifiesto del host; NO debe haber veredicto")
175    if not os.path.isdir(RFS):
176        fatal(f"no está el rootfs en {RFS} — corré antes `scripts/atuq-nested.sh`, que lo hidrata")
177    atuq = artefacto("atuq", "atuq", "ATUQ_DIR")
178    costura = artefacto("puriy-costura", "puriy-costura")
179    print(f"artefactos: {os.path.basename(atuq)}\n            {os.path.basename(costura)}")
180
181    puerto = puerto_libre()
182    servidor = http.server.ThreadingHTTPServer(("127.0.0.1", puerto), Silencioso)
183    threading.Thread(target=servidor.serve_forever, daemon=True).start()
184    url = f"http://127.0.0.1:{puerto}/pagina.html"
185
186    tmp = tempfile.mkdtemp(prefix="atuq-sct-")
187    salida = os.path.join(tmp, "salida")
188    capa = os.path.join(tmp, "capa")
189    os.makedirs(os.path.join(salida, "perfil"))
190    os.makedirs(capa)
191    try:
192        with open(os.path.join(salida, "perfil", "user.js"), "w") as fh:
193            fh.write('user_pref("browser.dom.window.dump.enabled", true);\n')
194            fh.write('user_pref("browser.shell.checkDefaultBrowser", false);\n')
195
196        cargas = []
197        for i in range(1, 7):
198            Silencioso.cuerpo_script = SCRIPT_CAMBIADO if i == 6 else SCRIPT_ORIGINAL
199            lineas, log = correr(atuq, costura, salida, capa, url, negativo)
200            cargas.append(lineas)
201            marca = " (script CAMBIADO)" if i == 6 else ""
202            print(f"\n-- carga {i}{marca}")
203            for l in lineas:
204                print("   " + l)
205            if not lineas and not negativo:
206                destino = os.path.join(ROOT, "work", "atuq-sct.log")
207                os.makedirs(os.path.dirname(destino), exist_ok=True)
208                open(destino, "w").write(log)
209                fatal(f"la extensión no dijo NADA en la carga {i} — log en {destino}")
210
211        def veredicto(lineas):
212            for l in lineas:
213                if l.startswith("VEREDICTO "):
214                    campos = dict(p.split("=", 1) for p in l.split() if "=" in p)
215                    return campos
216            return None
217
218        def eventos(lineas):
219            return [l for l in lineas if l.startswith("EVENTO ")]
220
221        def tandas_de(lineas):
222            return [l for l in lineas if l.startswith("TANDA ")]
223
224        def insignia(lineas):
225            for l in lineas:
226                if l.startswith("INSIGNIA tab="):
227                    return l.split("texto=", 1)[1] if "texto=" in l else None
228            return None
229
230        if negativo:
231            hubo = [i + 1 for i, c in enumerate(cargas) if veredicto(c)]
232            if hubo:
233                fatal(f"sin el manifiesto hubo veredicto en las cargas {hubo} — entonces el veredicto"
234                      " de la corrida positiva NO viene del host")
235            desconexion = any(l.startswith("DESCONECTADO") or l.startswith("SIN HOST")
236                              for c in cargas for l in c)
237            if not desconexion:
238                fatal("sin el manifiesto la extensión no se quejó: ¿está corriendo?")
239            print("\n✓ control negativo: sin el manifiesto no hay veredicto, y la extensión lo dice"
240                  " (el veredicto sale del host, no de la extensión)")
241            return 0
242
243        # ── LAS ASERCIONES VAN SOBRE LA SECUENCIA OBSERVADA, NO SOBRE UN CALENDARIO ──────────
244        # Dos hechos medidos el 2026-09-10, y los dos hacen que «carga N ⇒ visita N» sea falso:
245        #
246        # 1. **Una carga puede producir DOS tandas.** Hay peticiones del mismo documento que llegan con
247        #    `tabId = -1`, y la primera versión de la extensión agrupaba por `(tabId, documento)`. Cada
248        #    tanda es una visita, e inflar las visitas estabiliza el origen ANTES de conocer su código
249        #    real ⇒ alerta por churn legítimo. Arreglado agrupando por documento; se vigila abajo,
250        #    porque el síntoma sin esto se lee como «el contador va raro».
251        # 2. **La página que se abre EN EL ARRANQUE puede no ser observada.** Compite con la
252        #    inicialización de la extensión, y a veces gana. En uso real sólo afecta a la página que
253        #    abre el navegador al lanzarse (después la extensión ya está escuchando), así que no se
254        #    exige que TODA carga se observe: se exige que ninguna se observe DOS veces, y que la
255        #    secuencia observada sea la correcta.
256        for i, c in enumerate(cargas, start=1):
257            n = len(tandas_de(c))
258            if n > 1:
259                fatal(f"carga {i}: produjo {n} tandas y cada tanda es una VISITA para el TOFU"
260                      f" — contar visitas de más estabiliza el origen antes de tiempo: {tandas_de(c)}")
261
262        vistas = [(i + 1, veredicto(c), eventos(c), insignia(c))
263                  for i, c in enumerate(cargas) if veredicto(c)]
264        perdidas = [i + 1 for i, c in enumerate(cargas) if not veredicto(c)]
265        if perdidas:
266            print(f"\n(cargas sin observar: {perdidas} — la del arranque compite con la"
267                  " inicialización de la extensión)")
268        if perdidas and perdidas != [1]:
269            fatal(f"se perdieron cargas que no son la del arranque: {perdidas}")
270        if len(vistas) < 5:
271            fatal(f"sólo {len(vistas)} de 6 cargas se observaron: con menos de cinco no se puede"
272                  " afirmar la secuencia aprender→estabilizar→alertar")
273        if vistas[-1][0] != 6:
274            fatal("la carga del script CAMBIADO (la 6ª) no se observó — sin eso la prueba no"
275                  " concluye nada")
276
277        # Una carga observada = una visita: el número de visita tiene que seguir a la posición en la
278        # secuencia, sin saltos. Un salto es la señal de la tanda duplicada.
279        for pos, (nro, v, _, _) in enumerate(vistas, start=1):
280            if v.get("visitas") != str(pos):
281                fatal(f"carga {nro}: es la observación nº {pos} y el host contó"
282                      f" visitas={v.get('visitas')} — una carga observada tiene que ser una visita")
283
284        for pos in (1, 2, 3):
285            nro, v, ev, ins = vistas[pos - 1]
286            if v.get("fase") != "learning":
287                fatal(f"carga {nro} (observación {pos}): se esperaba fase=learning y salió {v}")
288            if ev:
289                fatal(f"carga {nro}: alertó mientras el origen APRENDÍA — eso es un falso positivo")
290        nro4, v4, ev4, _ = vistas[3]
291        if v4.get("fase") != "stable":
292            fatal(f"carga {nro4} (observación 4): se esperaba fase=stable (racha 3) y salió {v4}")
293        if ev4:
294            fatal(f"carga {nro4}: alertó en la observación que ESTABILIZA el origen: {ev4}")
295
296        # La anterior a la del cambio: mismo script, ya estable ⇒ CERO eventos. Es el control que hace
297        # que la última signifique algo: sin él, un detector que alertara en cada carga se vería igual.
298        nroA, vA, evA, insA = vistas[-2]
299        if evA:
300            fatal(f"carga {nroA}: alertó con el MISMO script ya estable — alertaría en cada carga, y"
301                  " un detector que grita todos los días no se mira más")
302        if vA.get("fase") != "stable":
303            fatal(f"carga {nroA}: se esperaba fase=stable antes del cambio y salió {vA}")
304        if insA != '""':
305            fatal(f"carga {nroA}: la insignia quedó en {insA} sin que hubiera novedad")
306
307        # Y la del script cambiado: UN evento, que nombra el script, y la insignia que lo dice.
308        nroZ, vZ, evZ, insZ = vistas[-1]
309        if len(evZ) != 1:
310            fatal(f"carga {nroZ}: con el script cambiado se esperaba UN evento y hubo"
311                  f" {len(evZ)}: {evZ}")
312        if "app.js" not in evZ[0]:
313            fatal(f"carga {nroZ}: el evento no nombra el script: {evZ[0]}")
314        # ── LA INSIGNIA, QUE ES LO ÚNICO QUE EL USUARIO VE DE TODA ESTA CADENA ────────────────
315        # La extensión la RELEE con `getBadgeText` después de ponerla, así que esto no es «se llamó a
316        # la API»: es lo que quedó puesto. Sin medirla, el aviso sería la única pieza sin verificar.
317        if insZ != '"1"':
318            fatal(f"carga {nroZ}: la insignia quedó en {insZ}, se esperaba \"1\" — el evento no"
319                  " llegó a la única parte que el usuario ve")
320
321        estado = os.path.join(salida, "hogar", ".local", "state", "puriy-costura")
322        ficheros = sorted(os.listdir(estado)) if os.path.isdir(estado) else []
323        if "registro.postcard" not in ficheros or "bitacora.postcard" not in ficheros:
324            fatal(f"el host no dejó su estado en disco (hay {ficheros}) — sin eso, cada arranque del"
325                  " navegador volvería a «aprendiendo» y nada alertaría nunca")
326        print(f"\nestado en disco: {ficheros}")
327
328        print("\n✓ la cadena entera anda: el navegador ejercita `filterResponseData` →"
329              " native messaging → puriy-costura → puriy-sct")
330        print("✓ una carga observada = una visita (el invariante que rompía agrupar por tabId)")
331        print("✓ y la insignia dice lo que tiene que decir: vacía en las cinco, «1» en la sexta")
332        print("✓ aprende tres cargas, se estabiliza en la cuarta, NO alerta en la quinta con el"
333              " mismo código, y alerta en la sexta con el código cambiado")
334        print("✓ y el aprendizaje sobrevive a cerrar el navegador seis veces: el estado está en disco")
335        return 0
336    finally:
337        servidor.shutdown()
338        shutil.rmtree(tmp, ignore_errors=True)
339
340
341sys.exit(main())