atuq: sct v1 — el navegador avisa cuando un sitio ya estable ejecuta código que nadie vio nunca
La unidad 6 del SDD 26, que es el diferenciador del §6 que no tiene ningún navegador. Cadena entera,
medida de punta a punta con un servidor HTTP real y seis cargas de página:
servidor HTTP → filterResponseData → connectNative → /usr/lib/mozilla/native-messaging-hosts/
→ puriy-costura --state → puriy-sct (TOFU + bitácora)
carga 1-3 (mismo script) fase=learning eventos=0 insignia vacía
carga 4 (mismo script) fase=stable eventos=0 insignia vacía
carga 5 (mismo script, estable) fase=stable eventos=0 insignia vacía ← control
carga 6 (script CAMBIADO) fase=stable eventos=1 insignia "1"
EVENTO ext:…/app.js 0ff4771fb797→f7ccb9fedfbd +27B
La extensión NO hashea ni guarda nada: ve bytes y pregunta. El registro es `puriy-sct`, del otro lado
del cable — duplicarlo en JS habría sido un segundo registro que se desalinea del primero, y el
primero es el que está certificado sin red.
CINCO COSAS QUE SE MIDIERON EN VEZ DE SUPONERSE, y las cinco fallan calladas:
1. el manifiesto va en `/usr/lib/mozilla/native-messaging-hosts/` y NO en el appdir: la ruta sale de
`XRESysNativeManifests`, un `/usr/lib/mozilla` COMPILADO dentro de Gecko;
2. **el manifiesto no puede llevar argumentos** — `NativeMessaging.sys.mjs` hace
`command = manifest.path` y los únicos argumentos son `[ruta-del-manifiesto, id]`. Y sin `--state`
el host corre en MEMORIA: cada arranque volvería a «aprendiendo» y nada alertaría nunca. De ahí el
lanzador `bin/puriy-costura-host`, que además decide la ruta del estado — dónde vive el estado de
un usuario es layout del FHS, o sea asunto de la distro y no del crate;
3. `filterResponseData` y el permiso `webRequestFilterResponse` SÍ están en nuestro `omni.ja`
(se le preguntó al artefacto, no a la documentación de Mozilla);
4. los scripts `inline` NO se ven por esta vía —`filterResponseData` entrega el cuerpo de una
PETICIÓN— y para v1 alcanza: el ataque que sct nombra es la sustitución en el CDN;
5. ⚠ **una carga de página puede producir dos peticiones del mismo documento, y una llega con
`tabId = -1`.** La primera versión agrupaba por `(tabId, documento)` y contaba esa carga como DOS
visitas. No es cosmético: inflar las visitas estabiliza el origen ANTES de conocer su código real,
y entonces alerta por churn legítimo — el falso positivo que la spec de puriy-sct pide evitar por
encima de todo. Ahora agrupa por documento (dos pestañas con la misma url cuentan UNA: es el error
seguro, tarda más en proteger y no alerta de más) y el guardián VIGILA el invariante «una carga,
una visita», así que si vuelve, falla ruidoso.
DOS CORRECCIONES DEL PROPIO §6.1, que decía «consulta al testigo antes de dejarla pasar»: el cable
del testigo es un POST con postcard, así que un JS no puede ser su cliente; y v1 OBSERVA Y AVISA, no
bloquea — es lo que puriy-sct dice de su propia v1, y poner un viaje entre procesos en el camino
crítico de cada script de cada página no es «más seguro», es un navegador que nadie usa.
EL AVISO SE MIDE, NO SE SUPONE: la extensión relee la insignia con `getBadgeText` después de ponerla,
y el guardián exige vacía en las cinco cargas sin novedad y "1" en la del script cambiado. Es la única
parte de la cadena que el usuario ve; dejarla en «se llamó a la API» era dejar sin medir el final.
CONTROL NEGATIVO: `--negative-control` borra el manifiesto y exige que NO haya veredicto — o sea que
el veredicto de la corrida positiva viene del host y no de la extensión inventándolo.
Y el aviso pasivo es decisión, no falta de tiempo: insignia y tooltip, no modal. Un modal por cada
despliegue de un sitio entrena a la gente a cerrarlo sin leer, y entonces el que importa también se
cierra.
Además: `rebrand.py` instala y CRUZA los manifiestos nativos (que el `path` exista y sea ejecutable
dentro del artefacto, y que sus `allowed_extensions` sean extensiones que de verdad empaquetamos), y
de paso se corrige el comentario del §4.ter que repetía la afirmación falsa sobre quién instala las
extensiones — lo mide `scripts/test-atuq-instalacion.py`: instala el escaneo de la carpeta.
`runtime = [..., "puriy-costura"]` en atuq.toml: sin eso la imagen llevaría manifiesto y extensión y
el host NO estaría, y la función se apagaría sola sin una línea de error. `yupana radio` confirma que
llega a las cuatro imágenes de escritorio.
⚠ Límite escrito en `fondo.js`, en el `lib.rs` del host y en los dos LEEME: se hashea el TEXTO ya
decodificado que entrega la extensión, no los bytes que sirvió el servidor. Vale para comparar dos
cargas nuestras; NO es comparable con el hash que publique un tercero sobre los bytes servidos, ni
con el de la v2, que engancha el script loader y ve los bytes reales.
Y una segunda cosa que el guardián encontró y que es del PRODUCTO, no del test: **la página que abre
el navegador al lanzarse puede no ser observada** — compite con la inicialización de la extensión, y
la carrera se gana o se pierde según la corrida. En uso real sólo afecta a esa primera página (después
la extensión ya está escuchando). Por eso las aserciones van sobre la SECUENCIA OBSERVADA y no sobre
un calendario: se exige que ninguna carga se observe dos veces, que la única que puede faltar sea la
del arranque, y que la secuencia aprender→estabilizar→no-alertar→alertar sea la correcta.
Sin regresiones: `test-atuq-politica.py` («guardianes: todos correctos», y sus cinco roturas siguen
matando el build con tres extensiones) y `test-atuq-inicio.py` (la home y la pestaña nueva siguen
siendo las nuestras) pasan sobre el artefacto final `b3:d3ced586`.
This commit is contained in:
@@ -506,10 +506,46 @@ no lo tiene nadie: ni Zen, ni Brave, ni Tor Browser.
|
||||
En tawasuyu ya está escrito y —dice su README— la lógica es **agnóstica del transporte** y está
|
||||
certificada sin red; `puriy-sct-testigo` es sólo el cable HTTP.
|
||||
|
||||
- **v1, sin tocar C++:** extensión con `webRequest` bloqueante que hashea cada respuesta de script y
|
||||
consulta al testigo antes de dejarla pasar.
|
||||
- **v1 — HECHA 2026-09-10, y con una corrección de este propio párrafo.** La extensión intercepta el
|
||||
cuerpo de cada `<script src>` con `filterResponseData` y se lo pasa al **host** (§7), que hashea y
|
||||
decide. Decía «consulta al testigo antes de dejarla pasar», y las dos mitades había que ajustarlas:
|
||||
el cable del testigo es un POST con **postcard**, así que un JS no puede ser su cliente (§7.quater);
|
||||
y v1 **observa y avisa, no bloquea** — que es lo que `puriy-sct` dice de su propia v1, y además
|
||||
bloquear cada script contra un viaje entre procesos no es «más seguro», es un navegador que nadie
|
||||
usa. Medida de punta a punta por `scripts/test-atuq-sct.py`, con servidor HTTP real, seis cargas de
|
||||
página y dos controles.
|
||||
- **v2, forma fuerte:** gancho en el script loader de Gecko. Eso sí es parche de árbol ⇒ §2.bis ⇒
|
||||
después de la toolchain del §3.
|
||||
después de la toolchain del §3. Es también lo único que ve los scripts **inline** —
|
||||
`filterResponseData` entrega el cuerpo de una PETICIÓN, y un inline viaja dentro del HTML— y lo único
|
||||
que puede negarse a ejecutar.
|
||||
|
||||
#### 6.1.bis Lo que costó armarla, que es lo que no se puede deducir (2026-09-10)
|
||||
|
||||
Cinco cosas se midieron en vez de suponerse, y cada una tenía su forma de fallar en silencio:
|
||||
|
||||
| Qué | Cómo falla si se supone mal |
|
||||
|---|---|
|
||||
| El manifiesto va en `/usr/lib/mozilla/native-messaging-hosts/`, no en el appdir (`XRESysNativeManifests` es un `/usr/lib/mozilla` **compilado**) | el puerto se cierra «sin error y sin mensaje» |
|
||||
| **El manifiesto NO puede llevar argumentos** — `NativeMessaging.sys.mjs`: `command = manifest.path`, y los únicos argumentos son `[ruta-del-manifiesto, id]` | `puriy-costura` sin `--state` corre en memoria ⇒ cada arranque vuelve a «aprendiendo» y **nada alerta nunca**: la función existe, no falla, y no protege |
|
||||
| `filterResponseData` + el permiso `webRequestFilterResponse` **están** en nuestro `omni.ja` | se habría escrito la extensión contra una API que el build no tiene |
|
||||
| Los `inline` no se ven por esta vía | se habría prometido cobertura total teniendo la mitad |
|
||||
| **Una carga puede producir dos peticiones del mismo documento, y una llega con `tabId = -1`** | agrupando por `(tabId, documento)` esa carga contaba **dos visitas** |
|
||||
|
||||
La última es la que más importa y la que menos se veía. Con las visitas infladas, un origen **se
|
||||
estabiliza antes de conocer su código real**, y entonces alerta por churn legítimo: el falso positivo
|
||||
que la spec de `puriy-sct` pide evitar por encima de todo. Se agrupa por documento —lo que hace que dos
|
||||
pestañas con la misma url cuenten UNA, que es el error seguro: tarda más en proteger, no alerta de
|
||||
más— y el guardián vigila el invariante «una carga, una visita», así que si vuelve, falla ruidoso.
|
||||
|
||||
**Y el aviso se mide, no se supone.** La extensión relee la insignia con `getBadgeText` después de
|
||||
ponerla y lo dice, así que el guardián exige insignia vacía en las cinco cargas sin novedad y `"1"` en
|
||||
la del script cambiado. Es la única parte de toda la cadena que el usuario llega a ver: dejarla como
|
||||
«se llamó a la API» sería dejar sin medir justo el final.
|
||||
|
||||
**Dos límites escritos donde no se pueden no leer** (el propio `fondo.js`, el `lib.rs` del host y los
|
||||
dos LEEME): se hashea el TEXTO ya decodificado, que vale para comparar dos cargas nuestras y **no** es
|
||||
comparable con el hash de los bytes servidos que publique un tercero; y el aviso es pasivo (insignia,
|
||||
no modal) porque un modal por despliegue entrena a cerrarlo sin leer.
|
||||
|
||||
### 6.2 Una descarga con identidad
|
||||
|
||||
@@ -1130,7 +1166,7 @@ siguiente.
|
||||
| 4.g | **El chrome se programa desde `atuq.cfg`, sin abrir `omni.ja`** ✅ 2026-09-09 — y de paso: la **vista dividida ya la trae el motor y viene prendida** (fx 154), ejercitada de verdad con `gBrowser.addTabSplitView` ⇒ `activeSplitView` + 2 navegadores. Guardián `scripts/test-atuq-chrome.py` con control negativo del propio motor | retira dos pendientes que eran de upstream, y deja el camino del chrome que NO pelea con el `jarlog` | — |
|
||||
| 4.h | **Quién instala las extensiones, medido** ✅ 2026-09-09 — tres escenarios en `scripts/test-atuq-instalacion.py`: instala el ESCANEO de `distribution/extensions/`, `install_url` con `file://` no instala nada. El README de la receta afirmaba lo contrario y quedó corregido | el mecanismo del que cuelga la extensión de `sct` deja de ser una nota al pie y pasa a estar vigilado | — |
|
||||
| 5 | **Host de native messaging — HECHO 2026-09-10.** `puriy-costura` en tawasuyu (`ebe41e96`) + el cable `shared/foreign-webext`, 14 tests sin navegador; y del lado de acá `recipes/puriy-costura.toml` ⇒ `b3:3b633431`, ELF estático de 952 K **verificado como artefacto**: contesta un marco de 4 bytes y hace la promesa del §6.1 completa (4 visitas aprendiendo → `stable` → un script cambiado = un evento). El camino ya estaba comprobado en el §7.bis con sonda y control negativo | los verbos del §6 | 4 ✅ |
|
||||
| 6 | `sct` v1 (extensión + testigo) | el diferenciador que nadie tiene | 5 |
|
||||
| 6 | **`sct` v1 — HECHA 2026-09-10.** Extensión `sct@atuq.tawasuyu` (`filterResponseData` → native messaging) + el manifiesto en `/usr/lib/mozilla/native-messaging-hosts/` + el lanzador que le pone el `--state` que el manifiesto no puede llevar. Guardián `scripts/test-atuq-sct.py`: servidor HTTP real, seis cargas, **una carga = una visita** vigilado, la insignia releída, y dos controles (la 5ª repite el mismo script y no alerta; sin manifiesto no hay veredicto) | el diferenciador que nadie tiene, andando | 5 ✅ |
|
||||
| 7 | Descargas al CAS | 6.2, y alimenta 6.9 | 5 |
|
||||
| 8 | Archivo + RAG | 6.3 | 5, 7 |
|
||||
| 9 | Torrent, medios, foco | 6.5–6.7, 6.9 | 5 |
|
||||
|
||||
+24
-1
@@ -103,6 +103,15 @@ cp /src/atuq.cfg "$DST/atuq.cfg"
|
||||
cp /src/chrome/atuq.css "$DST/chrome/atuq.css"
|
||||
cp /src/distribution/policies.json "$DST/distribution/policies.json"
|
||||
|
||||
# ── EL HOST NATIVO (SDD 26 §7) ────────────────────────────────────────────────────────────────
|
||||
# El lanzador va ANTES de `rebrand.py` a propósito: la herramienta comprueba que el `path` de cada
|
||||
# manifiesto de native messaging exista y sea ejecutable DENTRO del artefacto, y esa comprobación no
|
||||
# mide nada si el fichero se copia después. Un manifiesto que apunta a un ejecutable ausente no
|
||||
# falla: el navegador arranca, la extensión conecta, el puerto se cierra «sin error y sin mensaje» y
|
||||
# la función no existe. Es el peor informe posible, y ya nos costó una tarde (§7.bis).
|
||||
cp /src/bin/puriy-costura-host "$DST/puriy-costura-host"
|
||||
chmod 0755 "$DST/puriy-costura-host"
|
||||
|
||||
# ── EL BRANDING ───────────────────────────────────────────────────────────────────────────────
|
||||
# Todo lo que exige entrar al zip, editar el .ini o dibujar un PNG está en una herramienta aparte:
|
||||
# el shell no tiene `zip` en este lab, y `zipfile` además deja fijar orden, fecha y permisos, que
|
||||
@@ -136,6 +145,16 @@ for x in "$DST"/distribution/extensions/*.xpi; do
|
||||
echo " $(basename "$x") ($(wc -c < "$x") bytes)"
|
||||
done
|
||||
|
||||
# El manifiesto de native messaging: que exista y tenga bytes. `rebrand.py` ya cruzó su `path`
|
||||
# contra el árbol y sus `allowed_extensions` contra los XPI empaquetados; esto es el otro lado —que
|
||||
# el fichero llegara al artefacto—. Va en /usr/lib/mozilla y NO en el appdir porque esa ruta está
|
||||
# COMPILADA dentro de Gecko (`XRESysNativeManifests`) y el rebranding no la mueve.
|
||||
for m in /out/usr/lib/mozilla/native-messaging-hosts/*.json; do
|
||||
[ -s "$m" ] || { echo "atuq: el manifiesto nativo $m quedó vacío o no existe" >&2; exit 1; }
|
||||
echo " $(basename "$m") ($(wc -c < "$m") bytes)"
|
||||
done
|
||||
[ -x "$DST/puriy-costura-host" ] || { echo "atuq: el lanzador del host nativo no quedó ejecutable" >&2; exit 1; }
|
||||
|
||||
[ -x "$DST/atuq" ] || { echo "atuq: el binario renombrado no quedó ejecutable" >&2; exit 1; }
|
||||
[ -x /out/usr/bin/atuq ] || { echo "atuq: el lanzador no quedó ejecutable" >&2; exit 1; }
|
||||
grep -q '^Name=atuq' "$DST/application.ini" || { echo "atuq: application.ini no quedó rebrandeado" >&2; exit 1; }
|
||||
@@ -158,7 +177,11 @@ grep -q '^Name=atuq' "$DST/application.ini" || { echo "atuq: application.ini no
|
||||
# `.dev-fs/alpine` como capa de abajo, cada «atuq corre» de estos días resolvió `libstdc++.so.6`
|
||||
# contra el LAB. Corregido allá. Los dos ficheros ni se parecen —20.850.968 bytes el del corpus,
|
||||
# 2.804.104 el del lab—, así que ante la duda de cuál se cargó, se mira el tamaño.
|
||||
runtime = ["gcc-libs"]
|
||||
# Y `puriy-costura` (SDD 26 §7): el lanzador del appdir hace `exec /usr/bin/puriy-costura`, así que
|
||||
# sin esta línea la imagen llevaría el manifiesto y la extensión, y el host NO estaría — la función
|
||||
# se apagaría sola sin una línea de error. `runtime` no entra en el ArtifactHash: lo que cambia es la
|
||||
# CLAUSURA, que es lo que se hidrata.
|
||||
runtime = ["gcc-libs", "puriy-costura"]
|
||||
# `firefox` como dep de BUILD y no de runtime: su árbol se materializa en el sandbox, se copia
|
||||
# dentro del artefacto de atuq y a partir de ahí atuq no lo necesita más. El precio es que el
|
||||
# artefacto pesa lo que pesa Firefox otra vez; la salida barata (una granja de symlinks) rompe la
|
||||
|
||||
+68
-1
@@ -24,6 +24,9 @@ el vigente (por eso el ciclo es: editar todo → resellar una vez → medir), y
|
||||
| `distribution/policies.json` | `<appdir>/distribution/policies.json` | Política de fábrica: telemetría, updates, el MODO y la CONFIG de las extensiones (no su instalación — ver más abajo), los CONTENEDORES y la config de fábrica del proxy. |
|
||||
| `extensions/inicio/` | `<appdir>/distribution/extensions/inicio@atuq.tawasuyu.xpi` | Página de inicio y pestaña nueva. |
|
||||
| `extensions/proxy/` | `<appdir>/distribution/extensions/proxy@atuq.tawasuyu.xpi` | **Proxy por contenedor** (SDD 26 §6.8). |
|
||||
| `extensions/sct/` | `<appdir>/distribution/extensions/sct@atuq.tawasuyu.xpi` | **Transparencia de scripts** (SDD 26 §6.1). |
|
||||
| `native-messaging/*.json` | `/usr/lib/mozilla/native-messaging-hosts/` | El manifiesto del host nativo. **Fuera del appdir** — ver abajo. |
|
||||
| `bin/puriy-costura-host` | `<appdir>/puriy-costura-host` | El lanzador del host: le pone el `--state` que el manifiesto no puede. |
|
||||
|
||||
## Quién instala las extensiones — y este README decía lo contrario
|
||||
|
||||
@@ -96,6 +99,67 @@ un destino cerrado (127.0.0.1:9) y el navegador muestra el error. Salir directo
|
||||
silenciosa, que es la peor forma de este fallo. Y `proxyDNS` viene prendido: sin él la consulta DNS
|
||||
sale por la línea que se quería evitar.
|
||||
|
||||
## `sct` — transparencia de scripts (§6.1)
|
||||
|
||||
La joya del §6, y la primera pieza de atuq que habla con la suite. Qué hace: por cada carga de
|
||||
página, la extensión intercepta el cuerpo de cada `<script src>` con `filterResponseData` y se lo pasa
|
||||
al host nativo; el host —`puriy-costura`, que por dentro es `puriy-sct`— lo hashea con BLAKE3, aprende
|
||||
el conjunto de código estable de cada origen (TOFU) y **avisa cuando un sitio ya estable ejecuta código
|
||||
que nadie vio nunca**. Eso es el síntoma de un CDN envenenado, una cadena de suministro comprometida o
|
||||
un script dirigido a un usuario, y no lo tiene ningún navegador.
|
||||
|
||||
**La extensión no hashea ni guarda nada.** Ve bytes y hace preguntas; el registro es del núcleo del
|
||||
otro lado del cable. Duplicarlo en JS habría sido un segundo registro que se desalinea del primero, y
|
||||
el primero es el que está certificado sin red.
|
||||
|
||||
servidor HTTP → filterResponseData → connectNative → /usr/lib/mozilla/native-messaging-hosts/
|
||||
→ puriy-costura --state → puriy-sct (TOFU + bitácora fork-proof)
|
||||
|
||||
### Cinco cosas de esta cadena que no se pueden deducir, y por eso se midieron
|
||||
|
||||
1. **El manifiesto va en `/usr/lib/mozilla/native-messaging-hosts/`, NO en el appdir.** Gecko saca esa
|
||||
ruta de `XRESysNativeManifests`, que en Linux es un `/usr/lib/mozilla` **compilado**: el rebranding
|
||||
a `atuq` no lo mueve. Y nadie más en el corpus escribe ahí (comprobado sobre el store), así que la
|
||||
ruta es de atuq.
|
||||
2. **El manifiesto NO puede llevar argumentos.** En `omni.ja`, `modules/NativeMessaging.sys.mjs` hace
|
||||
`let command = hostInfo.manifest.path` y le pasa sólo `[ruta-del-manifiesto, id-de-la-extensión]`.
|
||||
Como `puriy-costura` sin `--state` corre todo en memoria —y entonces cada arranque del navegador
|
||||
volvería a «aprendiendo» y nada alertaría nunca—, hace falta el lanzador `bin/puriy-costura-host`.
|
||||
Es la peor forma de fallo: la función existe, no falla, y no protege de nada.
|
||||
3. **`filterResponseData` y el permiso `webRequestFilterResponse` existen en NUESTRO build** (están en
|
||||
`omni.ja`, `chrome/toolkit/content/extensions/schemas/web_request.json`). Se preguntó al artefacto
|
||||
antes de escribir la extensión, no a la documentación de Mozilla.
|
||||
4. **Los scripts `inline` no se ven por esta vía**, y no es un agujero disimulado: `filterResponseData`
|
||||
entrega el cuerpo de una PETICIÓN, y un inline viaja dentro del HTML. El ataque que `sct` nombra es
|
||||
la sustitución en el CDN, o sea exactamente el caso `<script src>`. Los inline los ve la v2, con el
|
||||
gancho en el script loader de Gecko.
|
||||
5. **Una carga de página puede producir MÁS DE UNA petición del mismo documento, y una llega con
|
||||
`tabId = -1`.** La primera versión agrupaba por `(tabId, documento)` y contaba esa carga como DOS
|
||||
visitas. No es cosmético: inflar las visitas **estabiliza el origen antes de tiempo**, y un origen
|
||||
estabilizado antes de conocer su código real alerta por churn legítimo — el falso positivo que la
|
||||
spec de `puriy-sct` pide evitar por encima de todo. Ahora se agrupa por documento, y
|
||||
`scripts/test-atuq-sct.py` **vigila el invariante**: una carga, una visita.
|
||||
|
||||
### v1 observa y avisa; no bloquea — y eso es del diseño
|
||||
|
||||
El §6.1 escribió «consulta al testigo antes de dejarla pasar». La forma fuerte es la v2. Acá se observa
|
||||
y se avisa por dos razones: `puriy-sct` es explícito en que su v1 **alerta y no bloquea** (bloquear
|
||||
necesita antes la acción del usuario, que es su M1), y poner un viaje ida-y-vuelta entre procesos en el
|
||||
camino crítico de cada script de cada página no es «más seguro»: es un navegador que nadie usa.
|
||||
|
||||
El aviso es **pasivo**: una insignia en la barra con el número de scripts nuevos y, en el tooltip, sus
|
||||
nombres. No un modal — un modal por cada despliegue entrena a la gente a cerrarlo sin leer, y entonces
|
||||
el que importa también se cierra.
|
||||
|
||||
⚠ **Y el límite que hay que decir primero:** se hashea el TEXTO ya decodificado que la extensión
|
||||
entrega, no los bytes que sirvió el servidor. La detección vale —el TOFU compara lo mismo contra lo
|
||||
mismo—, pero ese hash **no es comparable** con el que publique un tercero sobre los bytes servidos.
|
||||
|
||||
`scripts/test-atuq-sct.py` mide la cadena entera con un servidor HTTP real y seis cargas de página, y
|
||||
trae dos controles: la quinta carga repite el MISMO script ya estable y **no** debe alertar, y
|
||||
`--negative-control` borra el manifiesto y exige que no haya veredicto — o sea que el veredicto de la
|
||||
corrida positiva viene del host y no de la extensión inventándolo.
|
||||
|
||||
## Por qué el CSS no es `userChrome.css`
|
||||
|
||||
`userChrome.css` vive en el PERFIL del usuario y exige que el usuario prenda
|
||||
@@ -153,7 +217,10 @@ pestañas están fijadas borra el wrapper y devuelve `null`. `--negative-control
|
||||
|
||||
## Lo que TODAVÍA no está
|
||||
|
||||
- **La extensión de `sct`** y el host de native messaging (SDD 26 §6.1 y §7).
|
||||
- **La v2 de `sct`**: el gancho en el script loader de Gecko, que es lo único que ve los scripts
|
||||
`inline` y lo único que puede BLOQUEAR. Eso sí es parche del árbol (§2.bis).
|
||||
- **Los otros verbos del §6** sobre el host que ya está puesto: descargas al CAS (6.2), archivo con
|
||||
RAG (6.3), torrent (6.9), IA local en la barra lateral (6.7).
|
||||
*(El §6.8 ya no tiene pendientes: que una petición hecha en «Banco» sale por el proxy de «Banco»
|
||||
está probado por `scripts/test-atuq-ruteo.py`, que abre las dos pestañas por sessionstore y mira a
|
||||
qué puerto llama el navegador. Trae control negativo.)*
|
||||
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
#!/bin/sh
|
||||
# El lanzador del host nativo. Existe por UNA razón medida, no por gusto:
|
||||
#
|
||||
# ── EL MANIFIESTO DE NATIVE MESSAGING NO PUEDE LLEVAR ARGUMENTOS ──────────────────────────────
|
||||
# Comprobado dentro de nuestro propio artefacto (`omni.ja`, `modules/NativeMessaging.sys.mjs`):
|
||||
#
|
||||
# let command = hostInfo.manifest.path;
|
||||
# ...
|
||||
# arguments: [hostInfo.path, context.extension.id],
|
||||
#
|
||||
# O sea que Gecko ejecuta EXACTAMENTE el `path` del manifiesto y los únicos argumentos que le pasa
|
||||
# son la ruta del manifiesto y el id de la extensión. No hay campo para los nuestros.
|
||||
#
|
||||
# Y `puriy-costura` sin `--state` corre TODO EN MEMORIA: cada arranque del navegador volvería a la
|
||||
# fase «aprendiendo» y ningún cambio de script sería nunca un evento. O sea que sin este lanzador la
|
||||
# función existe, no falla, y no protege de nada — la peor de las tres.
|
||||
#
|
||||
# ── POR QUÉ LA RUTA LA DECIDE LA DISTRO Y NO EL CRATE ─────────────────────────────────────────
|
||||
# Dónde vive el estado de un usuario es layout del sistema de ficheros, que es asunto de takana;
|
||||
# `puriy-costura` no tiene por qué tener una opinión sobre el FHS de nadie. Se respeta
|
||||
# `XDG_STATE_HOME` porque es el estándar para estado que no es caché ni configuración.
|
||||
set -e
|
||||
DIR="${XDG_STATE_HOME:-$HOME/.local/state}/puriy-costura"
|
||||
mkdir -p "$DIR" 2>/dev/null || true
|
||||
# `exec` y no una llamada: el navegador le habla al proceso por stdin/stdout, así que el host TIENE
|
||||
# que heredar esos descriptores y ser el proceso que Gecko vigila. Con un `sh` de por medio, cerrar
|
||||
# el puerto mataría al shell y dejaría al host huérfano hablando a una tubería rota.
|
||||
exec /usr/bin/puriy-costura --state "$DIR"
|
||||
@@ -35,6 +35,10 @@
|
||||
"proxy@atuq.tawasuyu": {
|
||||
"installation_mode": "normal_installed",
|
||||
"install_url": "file:///usr/lib/atuq/distribution/extensions/proxy@atuq.tawasuyu.xpi"
|
||||
},
|
||||
"sct@atuq.tawasuyu": {
|
||||
"installation_mode": "normal_installed",
|
||||
"install_url": "file:///usr/lib/atuq/distribution/extensions/sct@atuq.tawasuyu.xpi"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
"use strict";
|
||||
// sct — transparencia de scripts (SDD 26 §6.1), v1.
|
||||
//
|
||||
// Lo que hace: por cada carga de página, junta los `<script src>` que se ejecutaron, y le pasa al
|
||||
// host nativo (`puriy-costura`) el origen y el TEXTO de cada uno. El host hashea con BLAKE3, aprende
|
||||
// el conjunto estable de cada sitio (TOFU) y contesta si ese sitio ya era estable y si ejecutó algo
|
||||
// que nadie vio nunca. Acá NO se hashea nada y NO se guarda ningún registro: quien sabe es
|
||||
// `puriy-sct`, del otro lado del cable. Duplicarlo en JS sería un segundo registro que se desalinea
|
||||
// del primero, y el primero es el que está certificado.
|
||||
//
|
||||
// ── V1 OBSERVA, NO BLOQUEA — Y ESO NO ES UNA CONCESIÓN ────────────────────────────────────────
|
||||
// El §6.1 escribió «consulta al testigo antes de dejarla pasar». La forma fuerte es la v2, con el
|
||||
// gancho en el script loader de Gecko. Acá se observa y se AVISA, por dos razones y las dos son del
|
||||
// diseño, no del apuro:
|
||||
// 1. `puriy-sct` es explícito: su v1 «ALERTA (no bloquea)». Un bloqueo necesita que exista antes
|
||||
// la acción del usuario (aceptar/pinnear un cambio), que es su M1.
|
||||
// 2. Bloquear cada script hasta que vuelva un mensaje del proceso nativo pondría un viaje
|
||||
// ida-y-vuelta entre procesos en el camino crítico de CADA script de CADA página. Eso no es
|
||||
// «más seguro»: es un navegador que nadie usa, y una función que nadie usa no protege a nadie.
|
||||
//
|
||||
// ── LO QUE ESTA VÍA NO PUEDE VER, DICHO ANTES DE QUE ALGUIEN LO SUPONGA ───────────────────────
|
||||
// `filterResponseData` entrega el cuerpo de una PETICIÓN. Los scripts **inline** no son una
|
||||
// petición: viajan dentro del HTML del documento, así que por esta vía son invisibles. Para v1 está
|
||||
// bien y no es un agujero disimulado: el ataque que `sct` nombra —sustitución en un CDN, supply
|
||||
// chain comprometido, script dirigido a un usuario— es exactamente el caso `<script src>`. Los
|
||||
// inline son código propio de la página, y los ve la v2.
|
||||
|
||||
const HOST = "puriy_costura";
|
||||
|
||||
// Tope de lo que se guarda por script. Un cuerpo más grande NO se manda a medias: hashear un texto
|
||||
// truncado daría un hash que no corresponde a nada, y eso no produce «menos detección» sino ALERTAS
|
||||
// FALSAS la próxima vez que el mismo script llegue entero. Se descarta y se dice.
|
||||
const TOPE_CUERPO = 4 * 1024 * 1024;
|
||||
|
||||
// Cuánto se espera, tras el último script de una página, antes de mandarle la tanda al host. Los
|
||||
// scripts no llegan con el documento —y los inyectados dinámicamente llegan bastante después—, así
|
||||
// que no hay un «fin de carga» que sirva de disparador. Un rebote es honesto: junta lo que llegó.
|
||||
const REBOTE_MS = 1500;
|
||||
|
||||
let puerto = null;
|
||||
const tandas = new Map(); // clave `${tabId}|${documento}` → { origen, scripts[], reloj }
|
||||
|
||||
function di(s) {
|
||||
// El veredicto sale por el stdout del proceso cuando `browser.dom.window.dump.enabled` está
|
||||
// prendida — que es cómo lo miden los guardianes de `scripts/test-atuq-*`. En producción está
|
||||
// apagada y esto no hace nada.
|
||||
try { dump("SCT " + s + "\n"); } catch (e) {}
|
||||
}
|
||||
|
||||
function conectar() {
|
||||
try {
|
||||
puerto = browser.runtime.connectNative(HOST);
|
||||
puerto.onMessage.addListener(alRecibir);
|
||||
puerto.onDisconnect.addListener(p => {
|
||||
// El host es un proceso largo; si se cae, la función tiene que poder volver. Un `puerto` que
|
||||
// queda muerto para siempre apaga `sct` en silencio hasta que alguien reinicie el navegador.
|
||||
const causa = p.error ? p.error.message : "sin error";
|
||||
di("DESCONECTADO " + causa);
|
||||
puerto = null;
|
||||
});
|
||||
di("CONECTADO " + HOST);
|
||||
} catch (e) {
|
||||
// `connectNative` tira si no hay permiso; que no haya manifiesto se ve en `onDisconnect`.
|
||||
di("SIN HOST " + e.message);
|
||||
puerto = null;
|
||||
}
|
||||
}
|
||||
|
||||
function enviar(msg) {
|
||||
if (!puerto) { conectar(); }
|
||||
if (!puerto) { return false; }
|
||||
try {
|
||||
puerto.postMessage(msg);
|
||||
return true;
|
||||
} catch (e) {
|
||||
di("ENVÍO FALLÓ " + e.message);
|
||||
puerto = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function alRecibir(r) {
|
||||
if (!r || r.ok !== true) {
|
||||
di("RECHAZO " + (r && r.error ? r.error : "sin error"));
|
||||
return;
|
||||
}
|
||||
if (r.verb !== "sct.observe") { return; }
|
||||
const eventos = r.events || [];
|
||||
di("VEREDICTO " + r.origin + " fase=" + r.phase + " visitas=" + r.visits +
|
||||
" recursos=" + r.resources + " eventos=" + eventos.length);
|
||||
for (const e of eventos) {
|
||||
di("EVENTO " + r.origin + " " + e.resource + " " + e.old.slice(0, 12) + "→" +
|
||||
e.new.slice(0, 12) + " " + (e.size_delta >= 0 ? "+" : "") + e.size_delta + "B");
|
||||
}
|
||||
// La pestaña sale del `id` que se mandó y el host DEVUELVE tal cual — para eso existe ese campo
|
||||
// del protocolo. Inventar un `tabId` en la respuesta habría sido agregarle al host un concepto del
|
||||
// navegador que no le toca conocer.
|
||||
pintar(typeof r.id === "number" ? r.id : -1, r, eventos);
|
||||
}
|
||||
|
||||
// El aviso es PASIVO: una insignia en la barra, no un modal. Un modal por cada despliegue de un
|
||||
// sitio entrena a la gente a cerrarlo sin leer, y entonces el que importa también se cierra.
|
||||
function pintar(tabId, r, eventos) {
|
||||
if (typeof tabId !== "number" || tabId < 0) { return; }
|
||||
try {
|
||||
if (eventos.length) {
|
||||
browser.browserAction.setBadgeText({ tabId, text: String(eventos.length) });
|
||||
browser.browserAction.setBadgeBackgroundColor({ tabId, color: "#c15728" });
|
||||
browser.browserAction.setTitle({
|
||||
tabId,
|
||||
title: "atuq: " + eventos.length + " script(s) que este sitio nunca había ejecutado:\n" +
|
||||
eventos.map(e => "· " + e.resource).join("\n"),
|
||||
});
|
||||
} else {
|
||||
browser.browserAction.setBadgeText({ tabId, text: "" });
|
||||
browser.browserAction.setTitle({
|
||||
tabId,
|
||||
title: "atuq · transparencia de scripts\n" + r.origin + " — " + r.phase +
|
||||
" (" + r.visits + " visita(s), " + r.resources + " recurso(s))",
|
||||
});
|
||||
}
|
||||
// Y se RELEE lo que quedó puesto. Poner una insignia y suponer que se puso es la forma de que el
|
||||
// aviso —lo único que el usuario llega a ver de todo esto— sea la pieza sin medir de la cadena.
|
||||
browser.browserAction.getBadgeText({ tabId }).then(
|
||||
t => di("INSIGNIA tab=" + tabId + " texto=" + JSON.stringify(t)),
|
||||
e => di("INSIGNIA NO SE PUDO LEER " + e.message));
|
||||
} catch (e) { di("INSIGNIA FALLÓ " + e.message); }
|
||||
}
|
||||
|
||||
// ⚠ LA CLAVE ES EL DOCUMENTO, **SIN** EL `tabId` — Y ESO ES MEDIDO, NO ESTILO (2026-09-10).
|
||||
// Con el `tabId` adentro, `scripts/test-atuq-sct.py` mostró DOS tandas para una sola carga de página:
|
||||
//
|
||||
// TANDA 1|http://…/pagina.html scripts=1
|
||||
// TANDA -1|http://…/pagina.html scripts=1 ← misma página, petición SIN pestaña
|
||||
//
|
||||
// Hay peticiones del mismo documento que llegan con `tabId = -1` (no pertenecen a ninguna pestaña), y
|
||||
// cada tanda es una VISITA para el TOFU. El efecto no es cosmético: **inflar las visitas estabiliza
|
||||
// el origen antes de tiempo**, y un origen estabilizado antes de conocer su código real alerta por
|
||||
// churn legítimo — o sea, el falso positivo que la spec de `puriy-sct` pide evitar por encima de todo.
|
||||
//
|
||||
// Agrupando por documento, dos pestañas con la MISMA url caen en una tanda: eso CUENTA DE MENOS, que
|
||||
// es el error seguro (tarda más en proteger, no alerta de más). El `tabId` sigue haciendo falta para
|
||||
// la insignia, así que se guarda el primero que no sea -1.
|
||||
function clave(d) {
|
||||
return d.documentUrl || d.originUrl || "";
|
||||
}
|
||||
|
||||
function anotar(d, texto) {
|
||||
const documento = d.documentUrl || d.originUrl;
|
||||
if (!documento) { return; } // sin documento no hay origen que registrar
|
||||
const k = clave(d);
|
||||
let t = tandas.get(k);
|
||||
if (!t) {
|
||||
t = { documento, tabId: -1, scripts: [], reloj: null };
|
||||
tandas.set(k, t);
|
||||
}
|
||||
if (t.tabId < 0 && typeof d.tabId === "number" && d.tabId >= 0) { t.tabId = d.tabId; }
|
||||
t.scripts.push({ url: d.url, body: texto, module: false });
|
||||
if (t.reloj) { clearTimeout(t.reloj); }
|
||||
t.reloj = setTimeout(() => vaciar(k), REBOTE_MS);
|
||||
}
|
||||
|
||||
function vaciar(k) {
|
||||
const t = tandas.get(k);
|
||||
tandas.delete(k);
|
||||
if (!t || !t.scripts.length) { return; }
|
||||
// La TANDA se anuncia con su clave y su tamaño. No es ruido de depuración: una MISMA carga de
|
||||
// página puede producir más de una tanda —el rebote las corta si un script llega tarde—, y cada
|
||||
// tanda es una VISITA para el TOFU. Contar visitas de más hace que un origen se estabilice antes
|
||||
// de tiempo, o sea que alerte por churn legítimo. Sin esta línea, eso se ve como «el contador va
|
||||
// raro» y no como qué lo causó.
|
||||
di("TANDA " + k + " scripts=" + t.scripts.length);
|
||||
const msg = { verb: "sct.observe", url: t.documento, scripts: t.scripts };
|
||||
// `id` es u64 del otro lado: una pestaña -1 (peticiones que no son de ninguna) no se manda, o el
|
||||
// mensaje entero se rechazaría por un campo que sólo servía para pintar una insignia.
|
||||
if (typeof t.tabId === "number" && t.tabId >= 0) { msg.id = t.tabId; }
|
||||
enviar(msg);
|
||||
}
|
||||
|
||||
// ── EL FILTRO ─────────────────────────────────────────────────────────────────────────────────
|
||||
// ⚠ REGLA DE ORO DE ESTE BLOQUE: los bytes SIEMPRE pasan. Un filtro que se rompe y no escribe deja
|
||||
// la respuesta colgada para siempre, y el síntoma no es «sct falló» sino «esta página no carga» —
|
||||
// una extensión de seguridad que rompe la web se desinstala, y con ella la seguridad. Por eso cada
|
||||
// camino tiene su `catch` y el `catch` desconecta el filtro, que es lo que deja fluir el resto.
|
||||
browser.webRequest.onBeforeRequest.addListener(
|
||||
d => {
|
||||
let filtro;
|
||||
try {
|
||||
filtro = browser.webRequest.filterResponseData(d.requestId);
|
||||
} catch (e) {
|
||||
di("FILTRO NO DISPONIBLE " + e.message);
|
||||
return {};
|
||||
}
|
||||
const trozos = [];
|
||||
let total = 0;
|
||||
let pasado = false;
|
||||
|
||||
filtro.ondata = e => {
|
||||
try {
|
||||
if (!pasado) {
|
||||
total += e.data.byteLength;
|
||||
if (total > TOPE_CUERPO) {
|
||||
// Se deja de acumular, pero NO se deja de escribir: la página sigue igual.
|
||||
pasado = true;
|
||||
trozos.length = 0;
|
||||
di("DEMASIADO GRANDE " + d.url + " (>" + TOPE_CUERPO + "B): no se observa");
|
||||
} else {
|
||||
trozos.push(new Uint8Array(e.data));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
pasado = true;
|
||||
}
|
||||
filtro.write(e.data);
|
||||
};
|
||||
|
||||
filtro.onstop = () => {
|
||||
try {
|
||||
if (!pasado && trozos.length) {
|
||||
const todo = new Uint8Array(total);
|
||||
let i = 0;
|
||||
for (const t of trozos) { todo.set(t, i); i += t.length; }
|
||||
// El texto YA DECODIFICADO es lo que se manda, y el host hashea su UTF-8. Está dicho allá
|
||||
// qué implica: el hash vale para comparar dos cargas nuestras, no para compararse con el
|
||||
// hash de los bytes que sirvió un tercero.
|
||||
anotar(d, new TextDecoder("utf-8", { fatal: false }).decode(todo));
|
||||
}
|
||||
} catch (err) {
|
||||
di("OBSERVAR FALLÓ " + err.message);
|
||||
}
|
||||
try { filtro.close(); } catch (err) { try { filtro.disconnect(); } catch (e2) {} }
|
||||
};
|
||||
|
||||
filtro.onerror = () => {
|
||||
// El navegador ya decidió (aborto, error de red): no hay nada que observar y nada que escribir.
|
||||
trozos.length = 0;
|
||||
};
|
||||
return {};
|
||||
},
|
||||
{ urls: ["<all_urls>"], types: ["script"] },
|
||||
["blocking"]
|
||||
);
|
||||
|
||||
// Una pestaña que se cierra no deja una tanda pendiente esperando un rebote que ya no sirve.
|
||||
browser.tabs.onRemoved.addListener(tabId => {
|
||||
for (const [k, t] of tandas) {
|
||||
if (t.tabId === tabId) {
|
||||
if (t.reloj) { clearTimeout(t.reloj); }
|
||||
tandas.delete(k);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
conectar();
|
||||
di("LISTA v1 observa y avisa; no bloquea");
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"manifest_version": 2,
|
||||
"name": "atuq — transparencia de scripts",
|
||||
"description": "Registra por origen el BLAKE3 de cada script que se ejecuta y avisa cuando un sitio ya estable corre código que nadie vio nunca (SDD 26 §6.1).",
|
||||
"version": "1.0",
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
"id": "sct@atuq.tawasuyu",
|
||||
"strict_min_version": "115.0"
|
||||
}
|
||||
},
|
||||
"permissions": [
|
||||
"webRequest",
|
||||
"webRequestBlocking",
|
||||
"webRequestFilterResponse",
|
||||
"nativeMessaging",
|
||||
"tabs",
|
||||
"<all_urls>"
|
||||
],
|
||||
"background": {
|
||||
"scripts": ["fondo.js"]
|
||||
},
|
||||
"browser_action": {
|
||||
"default_title": "Transparencia de scripts",
|
||||
"default_icon": "atuq128.png"
|
||||
},
|
||||
"icons": {
|
||||
"128": "atuq128.png"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "puriy_costura",
|
||||
"description": "La costura: el host nativo de atuq (SDD 26 §7). Le habla a puriy-sct.",
|
||||
"path": "/usr/lib/atuq/puriy-costura-host",
|
||||
"type": "stdio",
|
||||
"allowed_extensions": ["sct@atuq.tawasuyu"]
|
||||
}
|
||||
@@ -323,11 +323,15 @@ def empaquetar_extensiones():
|
||||
|
||||
|
||||
# ── 4.ter El cruce contra la política ─────────────────────────────────────────────────────────
|
||||
# El sideloading desde `distribution/extensions/` ya no existe en Firefox: el XPI se instala porque
|
||||
# `policies.json` lo declara en `ExtensionSettings` con un `install_url` ABSOLUTO. O sea que el
|
||||
# fichero y la política tienen que coincidir en id Y en ruta, y cuando no coinciden el fallo es
|
||||
# silencioso —el complemento sencillamente no aparece— o llega como un `Download failed` que no
|
||||
# nombra la causa. Se cruza acá, en el build, donde falla gratis.
|
||||
# ⚠ ESTE COMENTARIO DECÍA QUE EL SIDELOADING «YA NO EXISTE» Y QUE INSTALABA LA POLÍTICA. Es falso, y
|
||||
# está medido en tres escenarios por `scripts/test-atuq-instalacion.py` (SDD 26 §7.ter): **instala el
|
||||
# escaneo de `distribution/extensions/`**; con la política sola —`install_url` con `file://`— no se
|
||||
# instala NADA. La política FIJA el modo y CONFIGURA; instalar, no.
|
||||
#
|
||||
# El cruce sigue valiendo igual, y por eso no se toca: el id y la ruta tienen que coincidir en los dos
|
||||
# lados, porque una extensión empaquetada que la política no declara queda instalada pero sin su
|
||||
# `installation_mode` ni su `storage.managed`, y una política que apunta a un XPI que no está deja un
|
||||
# `Download failed` que no nombra la causa. Se cruza acá, en el build, donde falla gratis.
|
||||
def verificar_politica(resultados):
|
||||
ruta = os.path.join(APPDIR, "distribution", "policies.json")
|
||||
try:
|
||||
@@ -365,6 +369,79 @@ def verificar_politica(resultados):
|
||||
return contenedores
|
||||
|
||||
|
||||
# ── 4.quater Los manifiestos de native messaging ──────────────────────────────────────────────
|
||||
# DÓNDE VAN, Y POR QUÉ NO EN EL APPDIR. Gecko los busca en `XRESysNativeManifests`, que en Linux es
|
||||
# un `/usr/lib/mozilla` **compilado dentro del binario**: el rebranding a `atuq` no lo mueve. Se
|
||||
# midió con sonda y control negativo (SDD 26 §7.bis) — el control decía, con su nombre,
|
||||
# «No such native application».
|
||||
#
|
||||
# QUÉ SE CRUZA, y por qué las dos cosas: un manifiesto tiene DOS referencias a cosas que pueden no
|
||||
# existir, y las dos fallan igual de calladas.
|
||||
# · `path` — si el ejecutable no está, el navegador arranca, la extensión conecta, el puerto se
|
||||
# cierra «sin error y sin mensaje», y la función simplemente no existe;
|
||||
# · `allowed_extensions` — si nombra un id que no empaquetamos, el host es inalcanzable para
|
||||
# siempre y nada lo dice.
|
||||
# Las dos se comprueban contra el árbol REAL (el fichero que acabamos de escribir y la lista de XPI
|
||||
# que acabamos de empaquetar), no contra constantes de este script.
|
||||
#
|
||||
# Y se ESCRIBEN acá en vez de copiarse desde el overlay para que el JSON quede canónico —claves
|
||||
# ordenadas, sangría fija, un `\n` final—: un fichero copiado tal cual haría del formato del editor
|
||||
# de quien lo tocó una variable del artefacto.
|
||||
NM_DIR = "native-messaging"
|
||||
NM_DESTINO = os.path.join("usr", "lib", "mozilla", "native-messaging-hosts")
|
||||
|
||||
|
||||
def instalar_manifiestos_nativos(resultados):
|
||||
base = os.path.join(OVERLAY, NM_DIR)
|
||||
if not os.path.isdir(base):
|
||||
return []
|
||||
empaquetadas = {r[1] for r in resultados}
|
||||
destino_dir = os.path.join(OUTROOT, NM_DESTINO)
|
||||
os.makedirs(destino_dir, exist_ok=True)
|
||||
|
||||
salida = []
|
||||
for nombre in sorted(os.listdir(base)):
|
||||
if not nombre.endswith(".json"):
|
||||
continue
|
||||
ruta = os.path.join(base, nombre)
|
||||
try:
|
||||
with open(ruta, encoding="utf-8") as fh:
|
||||
man = json.load(fh)
|
||||
except (OSError, ValueError) as e:
|
||||
fatal(f"{NM_DIR}/{nombre}: no pude leerlo — {e}")
|
||||
|
||||
# El nombre del host ES el del fichero: Gecko lo busca por `<name>.json` y además compara el
|
||||
# campo `name` contra el que la extensión pidió. Dos dueños para un valor otra vez, así que
|
||||
# se exige que coincidan acá.
|
||||
esperado = nombre[:-len(".json")]
|
||||
if man.get("name") != esperado:
|
||||
fatal(f"{NM_DIR}/{nombre}: el campo name es {man.get('name')!r} y el fichero se llama "
|
||||
f"{esperado!r} — Gecko busca por nombre de fichero y compara el campo")
|
||||
if man.get("type") != "stdio":
|
||||
fatal(f"{NM_DIR}/{nombre}: type={man.get('type')!r}, se esperaba 'stdio'")
|
||||
ejecutable = man.get("path", "")
|
||||
if not ejecutable.startswith("/"):
|
||||
fatal(f"{NM_DIR}/{nombre}: path {ejecutable!r} no es absoluto (Gecko lo exige en Linux)")
|
||||
real = os.path.join(OUTROOT, ejecutable.lstrip("/"))
|
||||
if not os.path.isfile(real):
|
||||
fatal(f"{NM_DIR}/{nombre}: el host {ejecutable} NO está en el artefacto")
|
||||
if not os.access(real, os.X_OK):
|
||||
fatal(f"{NM_DIR}/{nombre}: el host {ejecutable} no es ejecutable")
|
||||
permitidas = man.get("allowed_extensions") or []
|
||||
if not permitidas:
|
||||
fatal(f"{NM_DIR}/{nombre}: sin allowed_extensions cualquier extensión podría hablarle")
|
||||
huerfanas = sorted(set(permitidas) - empaquetadas)
|
||||
if huerfanas:
|
||||
fatal(f"{NM_DIR}/{nombre}: allowed_extensions nombra extensiones que atuq no empaqueta: "
|
||||
f"{huerfanas}")
|
||||
|
||||
with open(os.path.join(destino_dir, nombre), "w", encoding="utf-8") as fh:
|
||||
json.dump(man, fh, ensure_ascii=False, indent=2, sort_keys=True)
|
||||
fh.write("\n")
|
||||
salida.append((esperado, ejecutable, permitidas))
|
||||
return salida
|
||||
|
||||
|
||||
# ── 5. Los binarios ────────────────────────────────────────────────────────────────────────────
|
||||
# Se renombran los dos y se deja un symlink `firefox` → `atuq` DENTRO del appdir. El symlink no es
|
||||
# indecisión: hay código y scripts de terceros que invocan el binario por su nombre histórico, y
|
||||
@@ -429,6 +506,7 @@ def main():
|
||||
parches = [rebrandear_binario(MARCA), rebrandear_binario(f"{MARCA}-bin")]
|
||||
exts = empaquetar_extensiones()
|
||||
contenedores = verificar_politica(exts)
|
||||
nativos = instalar_manifiestos_nativos(exts)
|
||||
|
||||
print(f"rebrand: overlay {huella[:16]}… ⇒ BuildID {bid}")
|
||||
print(f"rebrand: application.ini {sorted(claves)}")
|
||||
@@ -441,6 +519,8 @@ def main():
|
||||
for d, ext_id, n, size, _ in exts:
|
||||
print(f"rebrand: extensión de sistema {ext_id}.xpi ← {EXT_DIR}/{d} ({n} ficheros, {size} bytes)")
|
||||
print(f"rebrand: política cruzada — {len(exts)} extensión(es), contenedores {contenedores}")
|
||||
for nombre, ejecutable, permitidas in nativos:
|
||||
print(f"rebrand: host nativo {nombre} → {ejecutable} para {', '.join(permitidas)}")
|
||||
|
||||
|
||||
main()
|
||||
|
||||
Executable
+341
@@ -0,0 +1,341 @@
|
||||
#!/usr/bin/env python3
|
||||
"""¿`sct` avisa cuando un sitio ya estable ejecuta código que nadie vio nunca? (SDD 26 §6.1)
|
||||
|
||||
python3 scripts/test-atuq-sct.py # la cadena entera, seis cargas de página
|
||||
python3 scripts/test-atuq-sct.py --negative-control # sin el manifiesto: exige que NO haya veredicto
|
||||
|
||||
── QUÉ CADENA SE EJERCITA, Y POR QUÉ NINGUNA PIEZA ALCANZA SOLA ──────────────────────────────
|
||||
Un servidor HTTP de verdad sirve una página con un `<script src>`; el navegador la carga seis veces;
|
||||
la extensión `sct@atuq.tawasuyu` intercepta el cuerpo del script con `filterResponseData`, se lo pasa
|
||||
al host nativo por native messaging, y el host —`puriy-costura`, que por dentro es `puriy-sct`—
|
||||
hashea con BLAKE3, aprende el conjunto estable del origen y contesta. Son SEIS piezas en cadena:
|
||||
|
||||
servidor HTTP → filterResponseData → connectNative → manifiesto de /usr/lib/mozilla
|
||||
→ puriy-costura --state → puriy-sct (TOFU + bitácora)
|
||||
|
||||
Y el veredicto no se deduce de ninguna de ellas: sale del **motor**, por el `dump()` de la extensión,
|
||||
que es lo que el navegador de verdad va a mostrar en la insignia.
|
||||
|
||||
── LAS TRES AFIRMACIONES, Y LAS DOS QUE SON CONTROLES ────────────────────────────────────────
|
||||
cargas 1-3 con el MISMO script ⇒ fase `learning`, CERO eventos
|
||||
carga 4 con el MISMO script ⇒ fase `stable` (racha 3 de la Config por defecto)
|
||||
carga 5 con el MISMO script, YA estable ⇒ CERO eventos ← control: no alerta por cargar
|
||||
carga 6 con el script CAMBIADO ⇒ UN evento, con el hash viejo, el nuevo y el delta
|
||||
|
||||
Las cargas 5 y 6 son el par que hace que la prueba signifique algo: sin la 5, un detector que
|
||||
alertara en cada carga se vería idéntico a uno que funciona. Y `--negative-control` va más al hueso:
|
||||
borra el manifiesto de `/usr/lib/mozilla/native-messaging-hosts/` y exige que NO haya veredicto —o
|
||||
sea que el veredicto de la corrida positiva viene del host y no de la extensión inventándolo—.
|
||||
|
||||
── Y EL ESTADO QUEDA EN DISCO, QUE ES LO QUE HACE QUE ESTO PROTEJA ───────────────────────────
|
||||
El perfil y el `$HOME` se reusan entre las seis corridas, así que el navegador se ABRE Y SE CIERRA
|
||||
seis veces: si el host no persistiera, cada arranque volvería a `learning` y la carga 6 no alertaría
|
||||
nunca. Al final se le pregunta al propio binario del host, desde afuera, por el estado del origen.
|
||||
|
||||
REQUISITOS: el rootfs de `scripts/atuq-nested.sh`, y `atuq` + `puriy-costura` sellados.
|
||||
"""
|
||||
import http.server
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
RFS = os.environ.get("RFS", "/mnt/cosecha/escritorios/atuq-rootfs")
|
||||
ALPINE = os.path.join(ROOT, ".dev-fs/alpine")
|
||||
HAMMER = os.path.join(ROOT, "target/release/takana")
|
||||
STORE = os.environ.get("HAMMER_STORE", os.path.join(ROOT, "store"))
|
||||
MANIFIESTO = "/usr/lib/mozilla/native-messaging-hosts/puriy_costura.json"
|
||||
|
||||
SCRIPT_ORIGINAL = "window.__atuq_sct = 1;\n"
|
||||
SCRIPT_CAMBIADO = "window.__atuq_sct = 1; /* esto no lo vio nadie */\n"
|
||||
PAGINA = """<!doctype html><html><head><meta charset="utf-8"><title>sct</title>
|
||||
<script src="/app.js"></script></head><body>sct</body></html>
|
||||
"""
|
||||
|
||||
|
||||
def fatal(msg):
|
||||
print(f"✗ {msg}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
def artefacto(receta, nombre, escotilla=None):
|
||||
"""El artefacto VIGENTE, por `takana hash` y nunca por glob.
|
||||
|
||||
⚠ La escotilla (`ATUQ_DIR`) grita: el corpus es compartido y otro frente puede re-hashear
|
||||
`firefox`, dejando el atuq vigente sin sellar durante las horas de su build. Un resultado
|
||||
obtenido con la escotilla NO se cita como «el atuq de hoy pasa».
|
||||
"""
|
||||
forzado = os.environ.get(escotilla) if escotilla else None
|
||||
if forzado:
|
||||
if not os.path.isdir(forzado):
|
||||
fatal(f"{escotilla}={forzado} no existe")
|
||||
print(f"⚠ {escotilla}: se mide {os.path.basename(forzado)}, que NO es necesariamente el vigente")
|
||||
return forzado
|
||||
r = subprocess.run([HAMMER, "--store", STORE, "hash", f"recipes/{receta}.toml"],
|
||||
cwd=ROOT, capture_output=True, text=True)
|
||||
if r.returncode != 0:
|
||||
fatal(f"no pude hashear {receta}: {r.stderr.strip()}")
|
||||
h = r.stdout.strip().splitlines()[-1].removeprefix("b3:")
|
||||
d = os.path.join(STORE, f"{h}-{nombre}")
|
||||
if not os.path.isdir(d):
|
||||
fatal(f"{nombre} b3:{h[:8]} no está sellado — construilo antes")
|
||||
return d
|
||||
|
||||
|
||||
class Silencioso(http.server.SimpleHTTPRequestHandler):
|
||||
"""Sirve la página y el script desde memoria. El cuerpo del script lo cambia el test entre
|
||||
cargas, que es justo lo que `sct` tiene que notar."""
|
||||
|
||||
cuerpo_script = SCRIPT_ORIGINAL
|
||||
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
if self.path.startswith("/app.js"):
|
||||
datos, tipo = Silencioso.cuerpo_script.encode(), "application/javascript"
|
||||
else:
|
||||
datos, tipo = PAGINA.encode(), "text/html"
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", tipo)
|
||||
self.send_header("Content-Length", str(len(datos)))
|
||||
# Sin caché: si el navegador reusa el script de su caché, la carga 6 no vería el cambio y la
|
||||
# prueba diría «no alerta» por una razón que no tiene nada que ver con sct.
|
||||
self.send_header("Cache-Control", "no-store, no-cache, must-revalidate")
|
||||
self.end_headers()
|
||||
self.wfile.write(datos)
|
||||
|
||||
|
||||
def puerto_libre():
|
||||
s = socket.socket()
|
||||
s.bind(("127.0.0.1", 0))
|
||||
p = s.getsockname()[1]
|
||||
s.close()
|
||||
return p
|
||||
|
||||
|
||||
def correr(atuq, costura, salida, capa, url, negativo):
|
||||
"""Una carga de página: abre atuq headless con la URL y devuelve las líneas `SCT`."""
|
||||
borrar = f"rm -f {MANIFIESTO}" if negativo else "true"
|
||||
guion = f"""
|
||||
mkdir -p /root /salida/hogar
|
||||
[ -s /etc/machine-id ] || tr -d - < /proc/sys/kernel/random/uuid > /etc/machine-id
|
||||
{borrar}
|
||||
export HOME=/salida/hogar
|
||||
timeout 40 /usr/bin/atuq --profile /salida/perfil --no-remote --headless '{url}' \
|
||||
> /salida/consola.log 2>&1
|
||||
exit 0
|
||||
"""
|
||||
subprocess.run([
|
||||
"bwrap",
|
||||
"--overlay-src", ALPINE, "--overlay-src", RFS,
|
||||
"--overlay-src", atuq, "--overlay-src", costura, "--overlay-src", capa,
|
||||
"--tmp-overlay", "/",
|
||||
"--dev-bind", "/dev", "/dev", "--proc", "/proc", "--bind", "/sys", "/sys",
|
||||
"--tmpfs", "/tmp", "--tmpfs", "/run", "--unshare-pid",
|
||||
"--bind", salida, "/salida",
|
||||
"--uid", "0", "--gid", "0",
|
||||
"--setenv", "PATH", "/usr/bin:/bin:/usr/sbin:/sbin",
|
||||
"--setenv", "XDG_RUNTIME_DIR", "/run",
|
||||
"--setenv", "XDG_DATA_DIRS", "/usr/share", "--setenv", "MOZ_HEADLESS", "1",
|
||||
"--setenv", "MOZ_DISABLE_CONTENT_SANDBOX", "1",
|
||||
"--setenv", "MOZ_DISABLE_GMP_SANDBOX", "1",
|
||||
"--setenv", "MOZ_DISABLE_RDD_SANDBOX", "1",
|
||||
"--setenv", "MOZ_DISABLE_SOCKET_PROCESS_SANDBOX", "1",
|
||||
"--setenv", "MOZ_DISABLE_UTILITY_SANDBOX", "1",
|
||||
"sh", "-c", guion,
|
||||
], check=False)
|
||||
log = open(os.path.join(salida, "consola.log"), errors="replace").read()
|
||||
return [l[len("SCT "):] for l in log.splitlines() if l.startswith("SCT ")], log
|
||||
|
||||
|
||||
def preguntarle_al_host(costura, estado):
|
||||
"""Le pregunta al binario del host, DESDE AFUERA de la jaula, por el estado del origen. Es la
|
||||
evidencia de que lo que el navegador aprendió quedó en disco y no en la memoria de un proceso."""
|
||||
binario = os.path.join(costura, "usr/bin/puriy-costura")
|
||||
peticion = json.dumps({"verb": "sct.state", "url": "http://127.0.0.1/"}).encode()
|
||||
entrada = struct.pack("<I", len(peticion)) + peticion
|
||||
r = subprocess.run([binario, "--state", estado], input=entrada, capture_output=True, timeout=60)
|
||||
if len(r.stdout) < 4:
|
||||
return None
|
||||
n = struct.unpack("<I", r.stdout[:4])[0]
|
||||
return json.loads(r.stdout[4:4 + n])
|
||||
|
||||
|
||||
def main():
|
||||
negativo = "--negative-control" in sys.argv[1:]
|
||||
if negativo:
|
||||
print("== MODO CONTROL NEGATIVO: se borra el manifiesto del host; NO debe haber veredicto")
|
||||
if not os.path.isdir(RFS):
|
||||
fatal(f"no está el rootfs en {RFS} — corré antes `scripts/atuq-nested.sh`, que lo hidrata")
|
||||
atuq = artefacto("atuq", "atuq", "ATUQ_DIR")
|
||||
costura = artefacto("puriy-costura", "puriy-costura")
|
||||
print(f"artefactos: {os.path.basename(atuq)}\n {os.path.basename(costura)}")
|
||||
|
||||
puerto = puerto_libre()
|
||||
servidor = http.server.ThreadingHTTPServer(("127.0.0.1", puerto), Silencioso)
|
||||
threading.Thread(target=servidor.serve_forever, daemon=True).start()
|
||||
url = f"http://127.0.0.1:{puerto}/pagina.html"
|
||||
|
||||
tmp = tempfile.mkdtemp(prefix="atuq-sct-")
|
||||
salida = os.path.join(tmp, "salida")
|
||||
capa = os.path.join(tmp, "capa")
|
||||
os.makedirs(os.path.join(salida, "perfil"))
|
||||
os.makedirs(capa)
|
||||
try:
|
||||
with open(os.path.join(salida, "perfil", "user.js"), "w") as fh:
|
||||
fh.write('user_pref("browser.dom.window.dump.enabled", true);\n')
|
||||
fh.write('user_pref("browser.shell.checkDefaultBrowser", false);\n')
|
||||
|
||||
cargas = []
|
||||
for i in range(1, 7):
|
||||
Silencioso.cuerpo_script = SCRIPT_CAMBIADO if i == 6 else SCRIPT_ORIGINAL
|
||||
lineas, log = correr(atuq, costura, salida, capa, url, negativo)
|
||||
cargas.append(lineas)
|
||||
marca = " (script CAMBIADO)" if i == 6 else ""
|
||||
print(f"\n-- carga {i}{marca}")
|
||||
for l in lineas:
|
||||
print(" " + l)
|
||||
if not lineas and not negativo:
|
||||
destino = os.path.join(ROOT, "work", "atuq-sct.log")
|
||||
os.makedirs(os.path.dirname(destino), exist_ok=True)
|
||||
open(destino, "w").write(log)
|
||||
fatal(f"la extensión no dijo NADA en la carga {i} — log en {destino}")
|
||||
|
||||
def veredicto(lineas):
|
||||
for l in lineas:
|
||||
if l.startswith("VEREDICTO "):
|
||||
campos = dict(p.split("=", 1) for p in l.split() if "=" in p)
|
||||
return campos
|
||||
return None
|
||||
|
||||
def eventos(lineas):
|
||||
return [l for l in lineas if l.startswith("EVENTO ")]
|
||||
|
||||
def tandas_de(lineas):
|
||||
return [l for l in lineas if l.startswith("TANDA ")]
|
||||
|
||||
def insignia(lineas):
|
||||
for l in lineas:
|
||||
if l.startswith("INSIGNIA tab="):
|
||||
return l.split("texto=", 1)[1] if "texto=" in l else None
|
||||
return None
|
||||
|
||||
if negativo:
|
||||
hubo = [i + 1 for i, c in enumerate(cargas) if veredicto(c)]
|
||||
if hubo:
|
||||
fatal(f"sin el manifiesto hubo veredicto en las cargas {hubo} — entonces el veredicto"
|
||||
" de la corrida positiva NO viene del host")
|
||||
desconexion = any(l.startswith("DESCONECTADO") or l.startswith("SIN HOST")
|
||||
for c in cargas for l in c)
|
||||
if not desconexion:
|
||||
fatal("sin el manifiesto la extensión no se quejó: ¿está corriendo?")
|
||||
print("\n✓ control negativo: sin el manifiesto no hay veredicto, y la extensión lo dice"
|
||||
" (el veredicto sale del host, no de la extensión)")
|
||||
return 0
|
||||
|
||||
# ── LAS ASERCIONES VAN SOBRE LA SECUENCIA OBSERVADA, NO SOBRE UN CALENDARIO ──────────
|
||||
# Dos hechos medidos el 2026-09-10, y los dos hacen que «carga N ⇒ visita N» sea falso:
|
||||
#
|
||||
# 1. **Una carga puede producir DOS tandas.** Hay peticiones del mismo documento que llegan con
|
||||
# `tabId = -1`, y la primera versión de la extensión agrupaba por `(tabId, documento)`. Cada
|
||||
# tanda es una visita, e inflar las visitas estabiliza el origen ANTES de conocer su código
|
||||
# real ⇒ alerta por churn legítimo. Arreglado agrupando por documento; se vigila abajo,
|
||||
# porque el síntoma sin esto se lee como «el contador va raro».
|
||||
# 2. **La página que se abre EN EL ARRANQUE puede no ser observada.** Compite con la
|
||||
# inicialización de la extensión, y a veces gana. En uso real sólo afecta a la página que
|
||||
# abre el navegador al lanzarse (después la extensión ya está escuchando), así que no se
|
||||
# exige que TODA carga se observe: se exige que ninguna se observe DOS veces, y que la
|
||||
# secuencia observada sea la correcta.
|
||||
for i, c in enumerate(cargas, start=1):
|
||||
n = len(tandas_de(c))
|
||||
if n > 1:
|
||||
fatal(f"carga {i}: produjo {n} tandas y cada tanda es una VISITA para el TOFU"
|
||||
f" — contar visitas de más estabiliza el origen antes de tiempo: {tandas_de(c)}")
|
||||
|
||||
vistas = [(i + 1, veredicto(c), eventos(c), insignia(c))
|
||||
for i, c in enumerate(cargas) if veredicto(c)]
|
||||
perdidas = [i + 1 for i, c in enumerate(cargas) if not veredicto(c)]
|
||||
if perdidas:
|
||||
print(f"\n(cargas sin observar: {perdidas} — la del arranque compite con la"
|
||||
" inicialización de la extensión)")
|
||||
if perdidas and perdidas != [1]:
|
||||
fatal(f"se perdieron cargas que no son la del arranque: {perdidas}")
|
||||
if len(vistas) < 5:
|
||||
fatal(f"sólo {len(vistas)} de 6 cargas se observaron: con menos de cinco no se puede"
|
||||
" afirmar la secuencia aprender→estabilizar→alertar")
|
||||
if vistas[-1][0] != 6:
|
||||
fatal("la carga del script CAMBIADO (la 6ª) no se observó — sin eso la prueba no"
|
||||
" concluye nada")
|
||||
|
||||
# Una carga observada = una visita: el número de visita tiene que seguir a la posición en la
|
||||
# secuencia, sin saltos. Un salto es la señal de la tanda duplicada.
|
||||
for pos, (nro, v, _, _) in enumerate(vistas, start=1):
|
||||
if v.get("visitas") != str(pos):
|
||||
fatal(f"carga {nro}: es la observación nº {pos} y el host contó"
|
||||
f" visitas={v.get('visitas')} — una carga observada tiene que ser una visita")
|
||||
|
||||
for pos in (1, 2, 3):
|
||||
nro, v, ev, ins = vistas[pos - 1]
|
||||
if v.get("fase") != "learning":
|
||||
fatal(f"carga {nro} (observación {pos}): se esperaba fase=learning y salió {v}")
|
||||
if ev:
|
||||
fatal(f"carga {nro}: alertó mientras el origen APRENDÍA — eso es un falso positivo")
|
||||
nro4, v4, ev4, _ = vistas[3]
|
||||
if v4.get("fase") != "stable":
|
||||
fatal(f"carga {nro4} (observación 4): se esperaba fase=stable (racha 3) y salió {v4}")
|
||||
if ev4:
|
||||
fatal(f"carga {nro4}: alertó en la observación que ESTABILIZA el origen: {ev4}")
|
||||
|
||||
# La anterior a la del cambio: mismo script, ya estable ⇒ CERO eventos. Es el control que hace
|
||||
# que la última signifique algo: sin él, un detector que alertara en cada carga se vería igual.
|
||||
nroA, vA, evA, insA = vistas[-2]
|
||||
if evA:
|
||||
fatal(f"carga {nroA}: alertó con el MISMO script ya estable — alertaría en cada carga, y"
|
||||
" un detector que grita todos los días no se mira más")
|
||||
if vA.get("fase") != "stable":
|
||||
fatal(f"carga {nroA}: se esperaba fase=stable antes del cambio y salió {vA}")
|
||||
if insA != '""':
|
||||
fatal(f"carga {nroA}: la insignia quedó en {insA} sin que hubiera novedad")
|
||||
|
||||
# Y la del script cambiado: UN evento, que nombra el script, y la insignia que lo dice.
|
||||
nroZ, vZ, evZ, insZ = vistas[-1]
|
||||
if len(evZ) != 1:
|
||||
fatal(f"carga {nroZ}: con el script cambiado se esperaba UN evento y hubo"
|
||||
f" {len(evZ)}: {evZ}")
|
||||
if "app.js" not in evZ[0]:
|
||||
fatal(f"carga {nroZ}: el evento no nombra el script: {evZ[0]}")
|
||||
# ── LA INSIGNIA, QUE ES LO ÚNICO QUE EL USUARIO VE DE TODA ESTA CADENA ────────────────
|
||||
# La extensión la RELEE con `getBadgeText` después de ponerla, así que esto no es «se llamó a
|
||||
# la API»: es lo que quedó puesto. Sin medirla, el aviso sería la única pieza sin verificar.
|
||||
if insZ != '"1"':
|
||||
fatal(f"carga {nroZ}: la insignia quedó en {insZ}, se esperaba \"1\" — el evento no"
|
||||
" llegó a la única parte que el usuario ve")
|
||||
|
||||
estado = os.path.join(salida, "hogar", ".local", "state", "puriy-costura")
|
||||
ficheros = sorted(os.listdir(estado)) if os.path.isdir(estado) else []
|
||||
if "registro.postcard" not in ficheros or "bitacora.postcard" not in ficheros:
|
||||
fatal(f"el host no dejó su estado en disco (hay {ficheros}) — sin eso, cada arranque del"
|
||||
" navegador volvería a «aprendiendo» y nada alertaría nunca")
|
||||
print(f"\nestado en disco: {ficheros}")
|
||||
|
||||
print("\n✓ la cadena entera anda: el navegador ejercita `filterResponseData` →"
|
||||
" native messaging → puriy-costura → puriy-sct")
|
||||
print("✓ una carga observada = una visita (el invariante que rompía agrupar por tabId)")
|
||||
print("✓ y la insignia dice lo que tiene que decir: vacía en las cinco, «1» en la sexta")
|
||||
print("✓ aprende tres cargas, se estabiliza en la cuarta, NO alerta en la quinta con el"
|
||||
" mismo código, y alerta en la sexta con el código cambiado")
|
||||
print("✓ y el aprendizaje sobrevive a cerrar el navegador seis veces: el estado está en disco")
|
||||
return 0
|
||||
finally:
|
||||
servidor.shutdown()
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user