1# atuq — el envoltorio Gecko de la distro
2
3`atuq` (quechua: *zorro*) es el navegador de la distro: **un artefacto DERIVADO de `firefox`**, no un
4fork de su fuente. El diseño completo está en [`docs/26-atuq-envoltorio-gecko.md`](../../docs/26-atuq-envoltorio-gecko.md).
5
6Este directorio ES la fuente de atuq — `recipes/atuq.toml` lo declara con `source.dir` y takana lo
7hashea por CONTENIDO. Editar un fichero de acá mueve el `ArtifactHash`; no hay commit que pinear.
8
9⚠ **Y ESTE README también entra en el hash.** O sea que escribir acá el resultado de una medición
10invalida el artefacto sobre el que se midió: el hash que un párrafo cita es siempre el
11PREDECESOR del vigente. Es esperable y no es deuda —el rebuild son 6 s mientras el `firefox`
12vigente esté sellado—, pero hay que saberlo antes de leer un hash de acá y pensar que el fichero
13quedó viejo. Los guardianes de `scripts/test-atuq-*.py` se NIEGAN a medir un artefacto que no sea
14el vigente (por eso el ciclo es: editar todo → resellar una vez → medir), y cada reselle deja
15340 M más en el store: conviene juntar las ediciones en una.
16
17## Qué hay acá y dónde cae
18
19| Acá | En el artefacto | Qué hace |
20|---|---|---|
21| `prefs/autoconfig.js` | `defaults/pref/autoconfig.js` | Le dice a Gecko que lea `atuq.cfg`. Es el único gancho posible antes de que arranque el perfil. |
22| `atuq.cfg` | `<appdir>/atuq.cfg` | Prefs de fábrica + carga la hoja de estilo del chrome. |
23| `chrome/atuq.css` | `<appdir>/chrome/atuq.css` | El aspecto. Se registra como **USER_SHEET** desde `atuq.cfg` — el mismo nivel de cascada que `userChrome.css`, para que el usuario pueda pisarlo. (Decía AGENT_SHEET; el fichero nunca lo usó, y AGENT_SHEET además alcanzaría al contenido de las páginas.) |
24| `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. |
25| `extensions/inicio/` | `<appdir>/distribution/extensions/inicio@atuq.tawasuyu.xpi` | Página de inicio y pestaña nueva. |
26| `extensions/proxy/` | `<appdir>/distribution/extensions/proxy@atuq.tawasuyu.xpi` | **Proxy por contenedor** (SDD 26 §6.8). |
27| `extensions/sct/` | `<appdir>/distribution/extensions/sct@atuq.tawasuyu.xpi` | **Transparencia de scripts** (SDD 26 §6.1). |
28| `native-messaging/*.json` | `/usr/lib/mozilla/native-messaging-hosts/` | El manifiesto del host nativo. **Fuera del appdir** — ver abajo. |
29| `bin/puriy-costura-host` | `<appdir>/puriy-costura-host` | El lanzador del host: le pone el `--state` que el manifiesto no puede. |
30
31## Quién instala las extensiones — y este README decía lo contrario
32
33**Las instala el ESCANEO de `distribution/extensions/`.** `ExtensionSettings` de la política las
34FIJA y las CONFIGURA; instalarlas, no.
35
36⚠ Esta sección decía justo lo contrario —que el *sideloading* desde la carpeta «ya no funciona» y
37que instalaba la política— y era **falso**. Los dos mecanismos apuntan a los MISMOS ficheros, así
38que ninguna observación del artefacto los distingue y la política *parece* ser la que instala. Para
39saberlo hay que romper uno por vez, y eso es lo que hace `scripts/test-atuq-instalacion.py`
40(tres escenarios, medidos el 2026-09-09 sobre `atuq b3:fab2fbfb` y repetidos sobre `b3:9e16ac35`):
41
42| escenario | qué se rompe | resultado |
43|---|---|---|
44| `as-is` | nada | las dos extensiones puestas; la home es la nuestra |
45| `no-policy` | `policies.json` **sin** `ExtensionSettings` | **siguen puestas** y la home sigue siendo la nuestra ⇒ instala la carpeta |
46| `policy-only` | los XPI mudados fuera de la carpeta, con la política apuntando a la ruta nueva | **no aparecen** y la home vuelve a `about:home` ⇒ `install_url` con `file://` no instala nada |
47
48En las tres corridas hay un CONTROL por construcción: una extensión sonda que vive en la carpeta y
49que **ninguna política nombra**. Si la sonda habla, el escaneo está vivo en esa corrida; una corrida
50muda se declara rota en vez de leerse como «no instaló».
51
52Consecuencias prácticas para quien agregue una extensión:
53
541. el XPI **tiene que quedar en `distribution/extensions/`** — es lo único que la instala;
552. la entrada en `ExtensionSettings` no es opcional por eso: es la que la deja `normal_installed`
56 (el usuario la puede quitar) y la que le entrega su `storage.managed` por `3rdparty.Extensions`;
573. `extensions.autoDisableScopes = 0` en `atuq.cfg` es lo que evita que el escaneo las instale
58 **desactivadas**;
594. el `location` que `extensions.json` le pone al complemento **no dice de qué mecanismo salió**:
60 medido, los instalados por la carpeta también salen `app-profile`.
61
62## Una extensión nueva no cuesta nada, y su id tiene un solo dueño
63
64`extensions/<lo-que-sea>/` con un `manifest.json` adentro y ya: `rebrand.py` recorre el directorio,
65saca el id de `browser_specific_settings.gecko.id` —**no de una constante suya**— y escribe
66`<id>.xpi`. El icono lo inyecta desde `branding/icons/atuq128.png`, así que tampoco hay que copiarlo
67en cada extensión: la v0.4 tenía ese PNG duplicado byte a byte y con dos extensiones habrían sido
68tres copias que se desalinean en cuanto alguien actualice una sola.
69
70Y después **cruza los ids contra `policies.json`**, en los dos sentidos: una extensión empaquetada
71que la política no declara, o una política que apunta a un XPI que no existe, matan el build. Lo
72mismo con la ruta: el `install_url` tiene que ser exactamente donde el XPI quedó escrito. Antes eso
73era una constante contra otra constante; ahora una de las dos se DERIVA del fichero real.
74
75`scripts/test-atuq-politica.py` le pone delante las cinco formas conocidas de romper ese cruce y
76exige que las cinco maten el build (más un control con la política intacta, que tiene que pasar).
77
78## El proxy por contenedor (§6.8)
79
80Tres piezas, y ninguna alcanza sola:
81
821. `atuq.cfg` prende los contenedores (`privacy.userContext.enabled`), que Firefox trae apagados;
832. `policies.json` los CREA con `Containers.Default` —el único mecanismo que los pone en un perfil
84 NUEVO— y deja la config de fábrica del proxy en `3rdparty.Extensions`, que es de dónde la
85 extensión lee su `storage.managed`;
863. `extensions/proxy/` los enruta con `proxy.onRequest`, que es lo único que ve el `cookieStoreId`
87 de cada petición.
88
89La config se escribe por **nombre de contenedor** y no por `cookieStoreId`: el id depende del orden
90en que se crearon, así que la misma configuración aplicada a otro perfil apuntaría a otro
91contenedor. Dos capas: `storage.managed` es la de fábrica (la distro) y `storage.local` la del
92usuario, que pisa por contenedor.
93
94⚠ **Es separación de tráfico, NO anonimato**, y eso está escrito arriba de todo en la propia página
95de opciones, no en un pie de página. No toca la huella del navegador.
96
97**Fail closed.** Un contenedor que TIENE proxy configurado y no se pudo honrar no sale directo: va a
98un destino cerrado (127.0.0.1:9) y el navegador muestra el error. Salir directo sería una fuga
99silenciosa, que es la peor forma de este fallo. Y `proxyDNS` viene prendido: sin él la consulta DNS
100sale por la línea que se quería evitar.
101
102## `sct` — transparencia de scripts (§6.1)
103
104La joya del §6, y la primera pieza de atuq que habla con la suite. Qué hace: por cada carga de
105página, la extensión intercepta el cuerpo de cada `<script src>` con `filterResponseData` y se lo pasa
106al host nativo; el host —`puriy-costura`, que por dentro es `puriy-sct`— lo hashea con BLAKE3, aprende
107el conjunto de código estable de cada origen (TOFU) y **avisa cuando un sitio ya estable ejecuta código
108que nadie vio nunca**. Eso es el síntoma de un CDN envenenado, una cadena de suministro comprometida o
109un script dirigido a un usuario, y no lo tiene ningún navegador.
110
111**La extensión no hashea ni guarda nada.** Ve bytes y hace preguntas; el registro es del núcleo del
112otro lado del cable. Duplicarlo en JS habría sido un segundo registro que se desalinea del primero, y
113el primero es el que está certificado sin red.
114
115 servidor HTTP → filterResponseData → connectNative → /usr/lib/mozilla/native-messaging-hosts/
116 → puriy-costura --state → puriy-sct (TOFU + bitácora fork-proof)
117
118### Cinco cosas de esta cadena que no se pueden deducir, y por eso se midieron
119
1201. **El manifiesto va en `/usr/lib/mozilla/native-messaging-hosts/`, NO en el appdir.** Gecko saca esa
121 ruta de `XRESysNativeManifests`, que en Linux es un `/usr/lib/mozilla` **compilado**: el rebranding
122 a `atuq` no lo mueve. Y nadie más en el corpus escribe ahí (comprobado sobre el store), así que la
123 ruta es de atuq.
1242. **El manifiesto NO puede llevar argumentos.** En `omni.ja`, `modules/NativeMessaging.sys.mjs` hace
125 `let command = hostInfo.manifest.path` y le pasa sólo `[ruta-del-manifiesto, id-de-la-extensión]`.
126 Como `puriy-costura` sin `--state` corre todo en memoria —y entonces cada arranque del navegador
127 volvería a «aprendiendo» y nada alertaría nunca—, hace falta el lanzador `bin/puriy-costura-host`.
128 Es la peor forma de fallo: la función existe, no falla, y no protege de nada.
1293. **`filterResponseData` y el permiso `webRequestFilterResponse` existen en NUESTRO build** (están en
130 `omni.ja`, `chrome/toolkit/content/extensions/schemas/web_request.json`). Se preguntó al artefacto
131 antes de escribir la extensión, no a la documentación de Mozilla.
1324. **Los scripts `inline` no se ven por esta vía**, y no es un agujero disimulado: `filterResponseData`
133 entrega el cuerpo de una PETICIÓN, y un inline viaja dentro del HTML. El ataque que `sct` nombra es
134 la sustitución en el CDN, o sea exactamente el caso `<script src>`. Los inline los ve la v2, con el
135 gancho en el script loader de Gecko.
1365. **Una carga de página puede producir MÁS DE UNA petición del mismo documento, y una llega con
137 `tabId = -1`.** La primera versión agrupaba por `(tabId, documento)` y contaba esa carga como DOS
138 visitas. No es cosmético: inflar las visitas **estabiliza el origen antes de tiempo**, y un origen
139 estabilizado antes de conocer su código real alerta por churn legítimo — el falso positivo que la
140 spec de `puriy-sct` pide evitar por encima de todo. Ahora se agrupa por documento, y
141 `scripts/test-atuq-sct.py` **vigila el invariante**: una carga, una visita.
142
143### v1 observa y avisa; no bloquea — y eso es del diseño
144
145El §6.1 escribió «consulta al testigo antes de dejarla pasar». La forma fuerte es la v2. Acá se observa
146y se avisa por dos razones: `puriy-sct` es explícito en que su v1 **alerta y no bloquea** (bloquear
147necesita antes la acción del usuario, que es su M1), y poner un viaje ida-y-vuelta entre procesos en el
148camino crítico de cada script de cada página no es «más seguro»: es un navegador que nadie usa.
149
150El aviso es **pasivo**: una insignia en la barra con el número de scripts nuevos y, en el tooltip, sus
151nombres. No un modal — un modal por cada despliegue entrena a la gente a cerrarlo sin leer, y entonces
152el que importa también se cierra.
153
154⚠ **Y el límite que hay que decir primero:** se hashea el TEXTO ya decodificado que la extensión
155entrega, no los bytes que sirvió el servidor. La detección vale —el TOFU compara lo mismo contra lo
156mismo—, pero ese hash **no es comparable** con el que publique un tercero sobre los bytes servidos.
157
158`scripts/test-atuq-sct.py` mide la cadena entera con un servidor HTTP real y seis cargas de página, y
159trae dos controles: la quinta carga repite el MISMO script ya estable y **no** debe alertar, y
160`--negative-control` borra el manifiesto y exige que no haya veredicto — o sea que el veredicto de la
161corrida positiva viene del host y no de la extensión inventándolo.
162
163## Por qué el CSS no es `userChrome.css`
164
165`userChrome.css` vive en el PERFIL del usuario y exige que el usuario prenda
166`toolkit.legacyUserProfileCustomizations.stylesheets`. Una distro no puede depender de eso: el
167navegador tiene que verse como atuq la primera vez que arranca, con un perfil recién creado. La vía
168que sí funciona a nivel de aplicación es el `nsIStyleSheetService` desde autoconfig, que es lo que
169hace `atuq.cfg`.
170
171## La marca
172
173El arte viene de un tablero de identidad del operador (2026-09-05): un **zorro en medallón** con
174cenefa andina, sobre disco teal y anillo noche.
175
176- `branding/icons/atuq{16,32,48,64,128,256}.png` — el medallón recortado del tablero, con máscara
177 circular y alfa, reducido con LANCZOS desde un master de 1024 px. Se generan una vez y se copian;
178 no se dibujan en cada build.
179- La **paleta del chrome se muestreó del propio medallón**, tomando el color más frecuente de cada
180 zona, en vez de estimarla a ojo: `#c15728` el zorro, `#1b6470` el disco, `#04161d` el anillo,
181 `#f5ecde` los reflejos. Un token escrito «parecido» es cómo una identidad se desalinea de su logo
182 en tres ediciones.
183- **Del tablero se toman la forma y el color; los eslóganes NO** — el operador los descartó
184 explícitamente. `brand.ftl` lleva el nombre y nada más.
185
186⚠ A 16 px el medallón pierde el detalle y queda como una mancha cálida redonda. Es intrínseco a un
187logo con cenefa, no un defecto del escalado: la salida sería un dibujo simplificado para ese tamaño,
188y eso lo decide quien dibujó el zorro.
189
190## El chrome se programa desde `atuq.cfg`, no abriendo `omni.ja`
191
192Esto era un pendiente («el chrome de verdad dentro de `omni.ja`») y resultó ser dos cosas falsas.
193Medido con `scripts/test-atuq-chrome.py` sobre `atuq b3:9e16ac35`:
194
1951. **No hace falta entrar al zip para programar el chrome.** `atuq.cfg` ya corre con privilegios —de
196 ahí sale `nsIStyleSheetService`—, así que desde ahí se observa `browser-delayed-startup-finished`
197 y se toca el `gBrowser` de CADA ventana. La sonda abre dos pestañas y las parte en dos, desde
198 `atuq.cfg`, sin tocar `omni.ja`.
199
200 Y es la vía que hay que usar, no un atajo: re-empacar `omni.ja` pelea con el orden del `jarlog`
201 del PGO —por eso el `jarlog` está aparcado—, así que **cada función nueva del chrome que NO entre
202 al zip es una que no compite con la optimización de arranque.**
203
2042. **La vista dividida ya la trae el motor, y prendida.** Firefox 154 lleva
205 `tabbrowser/tabsplitview.js` (492 líneas), `opentabs-splitview.mjs` y `split-view-footer.js`
206 dentro de `browser/omni.ja`, y `defaults/preferences/firefox.js` dice
207 `pref("browser.tabs.splitView.enabled", true)`. La sonda lo ejercita de verdad —
208 `gBrowser.addTabSplitView([t1, t2])` ⇒ `activeSplitView` puesta y `splitViewBrowsers.length == 2`
209 — así que no es «existe el fichero»: **funciona en nuestro build**. No hay nada que escribir acá.
210
211⚠ Lo que esto NO dice: atuq **todavía no lleva JS de chrome propio**. Lo medido es el GANCHO, con la
212sonda inyectada por una capa de overlay que no viaja en el artefacto. Cuando haya una función que lo
213justifique, el fichero va en `chrome/atuq.js` y lo carga `atuq.cfg` igual que la hoja de estilo.
214
215El control negativo de la sonda sale del propio motor: `addTabSplitView` documenta que si TODAS las
216pestañas están fijadas borra el wrapper y devuelve `null`. `--negative-control` las fija y exige eso.
217
218## Lo que TODAVÍA no está
219
220- **La v2 de `sct`**: el gancho en el script loader de Gecko, que es lo único que ve los scripts
221 `inline` y lo único que puede BLOQUEAR. Eso sí es parche del árbol (§2.bis).
222- **Los otros verbos del §6** sobre el host que ya está puesto: descargas al CAS (6.2), archivo con
223 RAG (6.3), torrent (6.9), IA local en la barra lateral (6.7).
224*(El §6.8 ya no tiene pendientes: que una petición hecha en «Banco» sale por el proxy de «Banco»
225está probado por `scripts/test-atuq-ruteo.py`, que abre las dos pestañas por sessionstore y mira a
226qué puerto llama el navegador. Trae control negativo.)*
README.md
atuq — el envoltorio Gecko de la distro
atuq (quechua: zorro) es el navegador de la distro: un artefacto DERIVADO de firefox, no un
fork de su fuente. El diseño completo está en docs/26-atuq-envoltorio-gecko.md.
Este directorio ES la fuente de atuq — recipes/atuq.toml lo declara con source.dir y takana lo
hashea por CONTENIDO. Editar un fichero de acá mueve el ArtifactHash; no hay commit que pinear.
⚠ Y ESTE README también entra en el hash. O sea que escribir acá el resultado de una medición
invalida el artefacto sobre el que se midió: el hash que un párrafo cita es siempre el
PREDECESOR del vigente. Es esperable y no es deuda —el rebuild son 6 s mientras el firefox
vigente esté sellado—, pero hay que saberlo antes de leer un hash de acá y pensar que el fichero
quedó viejo. Los guardianes de scripts/test-atuq-*.py se NIEGAN a medir un artefacto que no sea
el vigente (por eso el ciclo es: editar todo → resellar una vez → medir), y cada reselle deja
340 M más en el store: conviene juntar las ediciones en una.
Qué hay acá y dónde cae
| Acá | En el artefacto | Qué hace |
prefs/autoconfig.js | defaults/pref/autoconfig.js | Le dice a Gecko que lea atuq.cfg. Es el único gancho posible antes de que arranque el perfil. |
atuq.cfg | <appdir>/atuq.cfg | Prefs de fábrica + carga la hoja de estilo del chrome. |
chrome/atuq.css | <appdir>/chrome/atuq.css | El aspecto. Se registra como USER_SHEET desde atuq.cfg — el mismo nivel de cascada que userChrome.css, para que el usuario pueda pisarlo. (Decía AGENT_SHEET; el fichero nunca lo usó, y AGENT_SHEET además alcanzaría al contenido de las páginas.) |
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
Las instala el ESCANEO de distribution/extensions/. ExtensionSettings de la política las
FIJA y las CONFIGURA; instalarlas, no.
⚠ Esta sección decía justo lo contrario —que el sideloading desde la carpeta «ya no funciona» y
que instalaba la política— y era falso. Los dos mecanismos apuntan a los MISMOS ficheros, así
que ninguna observación del artefacto los distingue y la política parece ser la que instala. Para
saberlo hay que romper uno por vez, y eso es lo que hace scripts/test-atuq-instalacion.py
(tres escenarios, medidos el 2026-09-09 sobre atuq b3:fab2fbfb y repetidos sobre b3:9e16ac35):
| escenario | qué se rompe | resultado |
as-is | nada | las dos extensiones puestas; la home es la nuestra |
no-policy | policies.json sin ExtensionSettings | siguen puestas y la home sigue siendo la nuestra ⇒ instala la carpeta |
policy-only | los XPI mudados fuera de la carpeta, con la política apuntando a la ruta nueva | no aparecen y la home vuelve a about:home ⇒ install_url con file:// no instala nada |
En las tres corridas hay un CONTROL por construcción: una extensión sonda que vive en la carpeta y
que ninguna política nombra. Si la sonda habla, el escaneo está vivo en esa corrida; una corrida
muda se declara rota en vez de leerse como «no instaló».
Consecuencias prácticas para quien agregue una extensión:
- el XPI tiene que quedar en
distribution/extensions/ — es lo único que la instala;
- la entrada en
ExtensionSettings no es opcional por eso: es la que la deja normal_installed
(el usuario la puede quitar) y la que le entrega su storage.managed por 3rdparty.Extensions;
extensions.autoDisableScopes = 0 en atuq.cfg es lo que evita que el escaneo las instale
desactivadas;
- el
location que extensions.json le pone al complemento no dice de qué mecanismo salió:
medido, los instalados por la carpeta también salen app-profile.
Una extensión nueva no cuesta nada, y su id tiene un solo dueño
extensions/<lo-que-sea>/ con un manifest.json adentro y ya: rebrand.py recorre el directorio,
saca el id de browser_specific_settings.gecko.id —no de una constante suya— y escribe
<id>.xpi. El icono lo inyecta desde branding/icons/atuq128.png, así que tampoco hay que copiarlo
en cada extensión: la v0.4 tenía ese PNG duplicado byte a byte y con dos extensiones habrían sido
tres copias que se desalinean en cuanto alguien actualice una sola.
Y después cruza los ids contra policies.json, en los dos sentidos: una extensión empaquetada
que la política no declara, o una política que apunta a un XPI que no existe, matan el build. Lo
mismo con la ruta: el install_url tiene que ser exactamente donde el XPI quedó escrito. Antes eso
era una constante contra otra constante; ahora una de las dos se DERIVA del fichero real.
scripts/test-atuq-politica.py le pone delante las cinco formas conocidas de romper ese cruce y
exige que las cinco maten el build (más un control con la política intacta, que tiene que pasar).
El proxy por contenedor (§6.8)
Tres piezas, y ninguna alcanza sola:
atuq.cfg prende los contenedores (privacy.userContext.enabled), que Firefox trae apagados;
policies.json los CREA con Containers.Default —el único mecanismo que los pone en un perfil
NUEVO— y deja la config de fábrica del proxy en 3rdparty.Extensions, que es de dónde la
extensión lee su storage.managed;
extensions/proxy/ los enruta con proxy.onRequest, que es lo único que ve el cookieStoreId
de cada petición.
La config se escribe por nombre de contenedor y no por cookieStoreId: el id depende del orden
en que se crearon, así que la misma configuración aplicada a otro perfil apuntaría a otro
contenedor. Dos capas: storage.managed es la de fábrica (la distro) y storage.local la del
usuario, que pisa por contenedor.
⚠ Es separación de tráfico, NO anonimato, y eso está escrito arriba de todo en la propia página
de opciones, no en un pie de página. No toca la huella del navegador.
Fail closed. Un contenedor que TIENE proxy configurado y no se pudo honrar no sale directo: va a
un destino cerrado (127.0.0.1:9) y el navegador muestra el error. Salir directo sería una fuga
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
- 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.
- 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.
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.
- 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.
- 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
toolkit.legacyUserProfileCustomizations.stylesheets. Una distro no puede depender de eso: el
navegador tiene que verse como atuq la primera vez que arranca, con un perfil recién creado. La vía
que sí funciona a nivel de aplicación es el nsIStyleSheetService desde autoconfig, que es lo que
hace atuq.cfg.
La marca
El arte viene de un tablero de identidad del operador (2026-09-05): un zorro en medallón con
cenefa andina, sobre disco teal y anillo noche.
branding/icons/atuq{16,32,48,64,128,256}.png — el medallón recortado del tablero, con máscara
circular y alfa, reducido con LANCZOS desde un master de 1024 px. Se generan una vez y se copian;
no se dibujan en cada build.
- La paleta del chrome se muestreó del propio medallón, tomando el color más frecuente de cada
zona, en vez de estimarla a ojo:
#c15728 el zorro, #1b6470 el disco, #04161d el anillo,
#f5ecde los reflejos. Un token escrito «parecido» es cómo una identidad se desalinea de su logo
en tres ediciones.
- Del tablero se toman la forma y el color; los eslóganes NO — el operador los descartó
explícitamente.
brand.ftl lleva el nombre y nada más.
⚠ A 16 px el medallón pierde el detalle y queda como una mancha cálida redonda. Es intrínseco a un
logo con cenefa, no un defecto del escalado: la salida sería un dibujo simplificado para ese tamaño,
y eso lo decide quien dibujó el zorro.
El chrome se programa desde atuq.cfg, no abriendo omni.ja
Esto era un pendiente («el chrome de verdad dentro de omni.ja») y resultó ser dos cosas falsas.
Medido con scripts/test-atuq-chrome.py sobre atuq b3:9e16ac35:
-
No hace falta entrar al zip para programar el chrome. atuq.cfg ya corre con privilegios —de
ahí sale nsIStyleSheetService—, así que desde ahí se observa browser-delayed-startup-finished
y se toca el gBrowser de CADA ventana. La sonda abre dos pestañas y las parte en dos, desde
atuq.cfg, sin tocar omni.ja.
Y es la vía que hay que usar, no un atajo: re-empacar omni.ja pelea con el orden del jarlog
del PGO —por eso el jarlog está aparcado—, así que cada función nueva del chrome que NO entre
al zip es una que no compite con la optimización de arranque.
-
La vista dividida ya la trae el motor, y prendida. Firefox 154 lleva
tabbrowser/tabsplitview.js (492 líneas), opentabs-splitview.mjs y split-view-footer.js
dentro de browser/omni.ja, y defaults/preferences/firefox.js dice
pref("browser.tabs.splitView.enabled", true). La sonda lo ejercita de verdad —
gBrowser.addTabSplitView([t1, t2]) ⇒ activeSplitView puesta y splitViewBrowsers.length == 2
— así que no es «existe el fichero»: funciona en nuestro build. No hay nada que escribir acá.
⚠ Lo que esto NO dice: atuq todavía no lleva JS de chrome propio. Lo medido es el GANCHO, con la
sonda inyectada por una capa de overlay que no viaja en el artefacto. Cuando haya una función que lo
justifique, el fichero va en chrome/atuq.js y lo carga atuq.cfg igual que la hoja de estilo.
El control negativo de la sonda sale del propio motor: addTabSplitView documenta que si TODAS las
pestañas están fijadas borra el wrapper y devuelve null. --negative-control las fija y exige eso.
Lo que TODAVÍA no está
- 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.)