diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md
index f0e493d6..d7ffabab 100644
--- a/docs/DEVELOPMENT.md
+++ b/docs/DEVELOPMENT.md
@@ -332,6 +332,26 @@ cannot revoke its own signing authority. If compromise is suspected, stop publis
resume with a higher sequence signed by a trusted key. Clients reject rollback by sequence, so restoring an older
catalog always means republishing its contents under a new, higher sequence.
+### Consuming the official rule-pack feed
+
+`RulePackCatalogUpdater` is app-owned and has one compiled HTTPS source. It uses an ephemeral cookieless session,
+requires same-origin redirects and JSON, caps both declared and streamed response bytes, sends persisted ETag and
+Last-Modified validators, and permits only one request at a time. Healthy automatic checks run daily; failures back off
+from one hour to six hours and then 24 hours. Manual checks bypass only that schedule. Demo and XCTest startup establish
+the local trusted catalog but do not use the network unless a test explicitly injects a transport.
+
+The receipt watermark and exact envelope cache are separate SQLite rows. Startup reverifies the exact accepted bytes
+before saved configuration or exceptions can produce a snapshot, and capture does not start until that reload completes.
+If a higher watermark has missing, corrupt, expired-before-acceptance, or newly untrusted bytes, definitions and the
+snapshot stay empty while recovery fetches without validators; the bundled lower sequence is never silently restored.
+An already accepted envelope may reverify after expiry as stale last-known-good.
+
+Acceptance runs in `BEGIN IMMEDIATE`: rollback/equivocation checks, exact bytes, validators, scheduling state, and consent
+invalidation commit together. Only then may `RulePackStore` publish definitions or a snapshot. New packs default off;
+changed content preserves saved scope, app selection, exceptions, and historical activity but is effectively disabled.
+Every enabling Save carries the immutable pack and catalog receipt displayed when the editor opened, and SQLite checks
+the durable catalog again inside the save transaction.
+
## Releasing
```sh
diff --git a/docs/about/index.html b/docs/about/index.html
index dc495c4d..fcbba1bf 100644
--- a/docs/about/index.html
+++ b/docs/about/index.html
@@ -153,7 +153,7 @@
Drei offizielle Pakete sind integriert, sichtbar und standardmäßig deaktiviert. Flowlight prüft einen einzigen festgelegten HTTPS-Feed auf signierte Updates; neue oder geänderte Inhalte werden erst durchgesetzt, nachdem du genau diese Version geprüft und Sichern gedrückt hast. Verifizierte, akzeptierte Inhalte können bei einem Ausfall als sichtbar veralteter letzter bekannter funktionsfähiger Stand erhalten bleiben.
Sie blockieren nach Hostname und können daher keine Erstanbieterwerbung, Lücken im DOM oder Layout, Browser-Overlays, skriptgesteuerten Pop-ups oder Fenster nativer Apps entfernen.
⛔ Verweigert
Schutzregeln für die Tools eines Agenten
@@ -435,7 +436,7 @@
Ein Wächter, den du nicht bewachen musst
Bleibt auf deinem Mac
Eine lokale SQLite-Datei. Kein Konto, keine Cloud-Synchronisierung, keine Telemetrie.
Standardmäßig nur Metadaten
Hostnamen, Ports und Byte-Zahlen. HTTPS zu entschlüsseln ist ein eigener Modus, den du einschalten musst – und er bleibt auf deinem Mac.
Im Kernel gefiltert
Die Erfassung lässt nur DNS-Antworten und TLS-ClientHellos durch. Alles andere wird verworfen, bevor Flowlight es sieht.
-
Abfragen, die du steuerst
Um zu benennen, wem eine Adresse gehört, gehen nur öffentliche IPs ohne Hostnamen an Team Cymru – abschaltbar mit einem Klick. Die tägliche Update-Prüfung fragt GitHub nur nach der neuesten Version und lässt sich ebenfalls abschalten.
+
Abfragen, die du steuerst
Um zu benennen, wem eine Adresse gehört, gehen nur öffentliche IPs ohne Hostnamen an Team Cymru. Release- und signierte Regelpaket-Prüfungen senden Standard-HTTPS-Anfragen ohne Verkehrs- oder Paketeinstellungen. Jede Prüfung hat einen eigenen Abschalter.
Eine normale HTTPS-Anfrage nach der neuesten Version; GitHub sieht deine IP-Adresse und den User-Agent Flowlight/<version>
Beim Start und täglich, ab 0.1.2. Abschalten in Einstellungen › Allgemein
Update-Download
github.com
Das Image und seine Prüfsummendatei
Nur wenn du auf „Herunterladen & öffnen“ klickst
+
Offizielle Regelpaket-Aktualisierung
flowlight.xinbetween.com
Eine standardmäßige HTTPS-Anfrage an den festgelegten offiziellen Feed. Der Server sieht deine IP-Adresse, den User-Agent Flowlight/<version> und bedingte Cache-Validatoren; er erhält weder Datenverkehr noch Paketeinstellungen oder eine Gerätekennung
Nach dem Start, sobald eine Prüfung fällig ist, und danach bei störungsfreiem Betrieb höchstens einmal täglich. Standardmäßig aktiviert; Ausschalter und manuelle Option Jetzt suchen unter Regeln › Pakete
Reverse-DNS
Der von dir eingerichtete DNS-Resolver
Eine Abfrage des registrierten Namens einer IP-Adresse, wenn kein Hostname gesehen wurde
Nach Bedarf, im Erfassungsmodus des Samplers, der Standard ist, bis die Netzwerkerweiterung läuft. Kein Schalter zum Abschalten: ohne sie kann der Sampler die meisten Verbindungen nicht benennen. Die Netzwerkerweiterung benennt sie aus dem Datenverkehr selbst und macht diese Abfrage nicht
Abfrage des Netzinhabers
Team Cymru, über DNS
Öffentliche IP-Adressen ohne bekannten Hostnamen. Private und reservierte Adressen verlassen deinen Mac nie
Standardmäßig ein. Abschalten in Erfassung › Netzinhaber bestimmen
Fragen mit einem gehosteten Modell
Der Anbieter, den du wählst: Anthropic, OpenAI, Google Gemini oder ein kompatibler Endpunkt
Deine Frage und die Ergebnisse der nur lesenden Flowlight-Abfragen, die das Modell ausgewählt hat. Die Verkehrsdatenbank selbst wird nicht hochgeladen
Nur wenn du einen gehosteten Anbieter wählst und eine Frage abschickst. Der Bildschirm Fragen zeigt die gesendeten Anfragen
HTTPS-Inspektion außerdem Anfrage- und Antwort-Bodys für drei Tage – das Sensibelste, was es überhaupt aufbewahrt. Header mit Zugangsdaten
werden nach Name und nach Wortbestandteil unlesbar gemacht, was eine Heuristik ist und keine Garantie; ein Header, der ein Geheimnis unter
einem Namen trägt, den nichts erkennt, kann aufgezeichnet werden.
-
Die Datenbank mit dem Datenverkehr wird nie hochgeladen. Drei kleinere Dinge verlassen den Mac, ohne dass jemand danach fragt, alle drei auf der
- Datenschutzseite benannt: die Update-Prüfung, die GitHub beim Start und täglich nach der neuesten Version
- fragt; die Abfrage zum Netzinhaber, die Team Cymru nach öffentlichen Adressen ohne Hostnamen fragt; und, solange Flowlight Zähler liest
- statt seine Erweiterung zu betreiben, eine Reverse-DNS-Abfrage der Adressen, zu denen es keinen Namen gesehen hat. Die ersten beiden haben
- Schalter zum Abschalten. Die dritte nicht, denn auf dem Sampler ist sie fast der einzige Weg, auf dem eine Verbindung überhaupt zu einem
- Namen kommt – sie geht an den Resolver, den der Mac schon benutzt, und sie hört auf, sobald die Erweiterung übernimmt und Verbindungen aus dem
- Datenverkehr selbst benennt. Fragen mit einem gehosteten Modell sendet weit mehr als alle drei und ist das eine, das aus bleibt, bis du es
- einrichtest.
+
Die Datenbank mit dem Datenverkehr wird nie hochgeladen. Die Datenschutzseite nennt jede ausgehende Anfrage: Release- und signierte Regelpaket-Prüfungen, die optionale Abfrage zum Netzinhaber und Reverse-DNS im Sampler. Die ersten drei haben Abschalter; Reverse-DNS nicht, weil der Sampler sonst die meisten Verbindungen nicht benennen könnte. Fragen mit einem gehosteten Modell senden weit mehr und bleiben aus, bis du sie einrichtest.
Updates
Versionen sind mit einer Developer ID signiert und notarisiert. Die Updatefunktion verlangt eine veröffentlichte Prüfsumme und verweigert
einen Download ohne sie, und prüft dann vor der Installation, dass die heruntergeladene App vom selben Team signiert ist wie die laufende
Kopie. Ein Update, das jemand anderes signiert hat, wird verweigert, auch wenn es sonst gültig ist.
+
Offizielle Regelpakete verwenden eine separate zweckgebundene Signatur und eine einzige festgelegte HTTPS-Quelle. Die App verwaltet unabhängig davon die höchste Sequenznummer, verifiziert beim Start die exakten zwischengespeicherten Bytes erneut, weist Rollbacks und widersprüchliche Veröffentlichungen zurück und behandelt eine Herausgebersignatur niemals als Erlaubnis, geänderte Inhalte durchzusetzen. Neue oder geänderte Pakete erfordern eine ausdrückliche Prüfung des exakten Inhalts und Sichern; veraltete akzeptierte Inhalte werden als letzter bekannter funktionsfähiger Stand gekennzeichnet, während ein Vertrauensentzug oder beschädigte Bytes sicherheitshalber zur Sperrung führen.
Cursor ⇥ raw.githubusercontent.com · rechazada 4 veces · de lunes a viernes 18:00–09:00
+
⛔ Previa activación
Paquetes gestionados de publicidad y rastreadores
Hay tres paquetes oficiales integrados, visibles y desactivados de forma predeterminada. Flowlight consulta un único feed HTTPS fijo para buscar actualizaciones firmadas; el contenido nuevo o modificado no se aplica hasta que revises esa versión exacta y pulses Guardar. Durante una interrupción, el contenido verificado y aceptado puede conservarse como la última versión válida conocida, marcada visiblemente como obsoleta.
Bloquean por nombre de host, por lo que no pueden eliminar anuncios propios del sitio, huecos del DOM o del diseño, superposiciones del navegador, ventanas emergentes generadas por scripts ni ventanas de aplicaciones nativas.
⛔ Rechaza
Salvaguardas sobre las herramientas de un agente
@@ -435,7 +436,7 @@
Un monitor que no tienes que vigilar
Se queda en tu Mac
Un único archivo SQLite local. Sin cuenta, sin sincronización en la nube, sin telemetría.
Metadatos por defecto
Nombres de host, puertos y bytes. Descifrar HTTPS es un modo aparte que tienes que activar, y también se queda en tu Mac.
Filtrado en el núcleo
La captura solo deja pasar las respuestas DNS y los ClientHello de TLS. Todo lo demás se descarta antes de que Flowlight lo vea.
-
Consultas que tú controlas
Para saber de quién es una dirección solo se envían a Team Cymru las IP públicas sin nombre de host, y se desactiva con un clic. La comprobación diaria de actualizaciones solo pregunta a GitHub por la última versión, y también puede desactivarse.
+
Consultas que tú controlas
Para saber de quién es una dirección solo se envían a Team Cymru las IP públicas sin nombre de host. Las comprobaciones de versiones y de paquetes de reglas firmados envían solicitudes HTTPS estándar sin tráfico ni configuración de paquetes. Cada una tiene su propio interruptor.
diff --git a/docs/es/privacy/index.html b/docs/es/privacy/index.html
index bbe7d615..7d62b059 100644
--- a/docs/es/privacy/index.html
+++ b/docs/es/privacy/index.html
@@ -48,7 +48,7 @@
-
+
Ir al contenido
@@ -125,6 +125,7 @@
Peticiones de red que hace la app
Petición
Enviada a
Qué contiene
Cuándo
Comprobación de actualizaciones
api.github.com
Una petición HTTPS normal de la última versión; GitHub ve tu dirección IP y el agente de usuario Flowlight/<version>
Al arrancar y a diario, desde la 0.1.2. Interruptor para desactivarla en Ajustes › General
Descarga de la actualización
github.com
La imagen de disco y su archivo de suma de verificación
Solo cuando pulsas Descargar y abrir
+
Actualización oficial del paquete de reglas
flowlight.xinbetween.com
Una solicitud HTTPS estándar al feed oficial fijo. El servidor ve tu dirección IP, el agente de usuario Flowlight/<version> y los validadores condicionales de caché; no recibe tráfico, ajustes de paquetes ni ningún identificador del dispositivo
Después del inicio, cuando corresponda, y luego como máximo una vez al día mientras todo funcione correctamente. Activada de forma predeterminada; interruptor para desactivarla y opción manual Buscar ahora en Reglas › Paquetes
DNS inverso
El servidor DNS que tengas configurado
Una consulta del nombre registrado de una dirección IP, cuando no se ha visto ningún nombre de host
Según haga falta, en el modo de captura del muestreador, que es el predeterminado hasta que la extensión de red esté en marcha. Sin interruptor para desactivarla: sin ella el muestreador no puede nombrar la mayoría de las conexiones. La extensión de red las nombra a partir del propio tráfico y no hace esta consulta
Consulta del propietario de la red
Team Cymru, por DNS
Direcciones IP públicas sin nombre de host conocido. Las direcciones privadas y reservadas no salen nunca de tu Mac
Activada por defecto. Interruptor para desactivarla en Captura › Identificar a los propietarios de la red
Preguntar con un modelo alojado
El proveedor que elijas: Anthropic, OpenAI, Google Gemini o un endpoint compatible
Tu pregunta y los resultados que devuelvan las consultas de solo lectura de Flowlight que elija el modelo. La base de datos de tráfico en sí no se sube
Solo cuando eliges un proveedor alojado y envías una pregunta. La pantalla Preguntar muestra las peticiones transmitidas
diff --git a/docs/es/threat-model/index.html b/docs/es/threat-model/index.html
index d10fb6d9..a3b8e38e 100644
--- a/docs/es/threat-model/index.html
+++ b/docs/es/threat-model/index.html
@@ -48,7 +48,7 @@
-
+
Ir al contenido
@@ -169,19 +169,13 @@
Lo que guarda Flowlight mismo
cuerpos de las peticiones y las respuestas durante tres días: lo más delicado que llega a conservar. Las cabeceras con credenciales se
censuran por nombre y por palabra, lo cual es una heurística y no una garantía; una cabecera que lleve un secreto con un nombre que nada
reconoce puede quedar registrada.
-
La base de datos de tráfico nunca se sube. Tres cosas más pequeñas sí salen del Mac sin que nadie las pida, las tres nombradas en la
- página de privacidad: la comprobación de actualizaciones, que pide a GitHub la última versión al arrancar y a
- diario; la consulta del propietario de la red, que pregunta a Team Cymru por direcciones públicas que no tienen nombre de host; y, mientras
- Flowlight lee contadores en vez de ejecutar su extensión, una consulta de DNS inverso de las direcciones para las que no vio ningún nombre.
- Las dos primeras se pueden desactivar. La tercera no, porque en el muestreador es casi la única manera en la que una conexión llega a tener
- nombre: va al servidor DNS que el Mac ya usa, y se detiene en cuanto la extensión toma el relevo y nombra las conexiones a partir del propio
- tráfico. Preguntar con un modelo alojado envía mucho más que cualquiera de ellas, y es lo único que está desactivado hasta que lo
- configuras.
+
La base de datos de tráfico nunca se sube. La página de privacidad enumera cada petición saliente: comprobaciones de versión y de paquetes de reglas firmados, la consulta opcional del propietario de red y DNS inverso en el muestreador. Las tres primeras tienen interruptores; el DNS inverso no, porque sin él el muestreador no podría nombrar la mayoría de las conexiones. Preguntar con un modelo alojado envía mucho más y permanece desactivado hasta que lo configuras.
Actualizaciones
Las versiones se firman con un Developer ID y se notarizan. El actualizador exige una suma de comprobación publicada y rechaza una descarga
que no la tenga; después comprueba que la app descargada esté firmada por el mismo equipo que la copia en ejecución antes de instalarla. Una
actualización firmada por otra persona se rechaza aunque por lo demás sea válida.
+
Los paquetes de reglas oficiales usan una firma independiente limitada a este fin y una única fuente HTTPS fija. La aplicación mantiene por separado una marca de la secuencia más alta, vuelve a verificar al iniciarse los bytes exactos almacenados en caché, rechaza las regresiones y las publicaciones contradictorias, y nunca considera la firma del editor como permiso para aplicar contenido modificado. Los paquetes nuevos o modificados requieren la revisión explícita del contenido exacto y Guardar; el contenido obsoleto ya aceptado se etiqueta como la última versión válida conocida, mientras que la revocación de la confianza o los bytes dañados provocan un rechazo seguro.
diff --git a/docs/fr/index.html b/docs/fr/index.html
index ae22ced8..a013e9fb 100644
--- a/docs/fr/index.html
+++ b/docs/fr/index.html
@@ -48,7 +48,7 @@
-
+
Aller au contenu
@@ -90,7 +90,7 @@
Voyez l’activité réseau de vos apps.
brew install --cask xinbetween/tap/flowlight
-
v0.15.3macOS 15+UniverselGPL-3.0Sans télémétrie
+
v0.15.4macOS 15+UniverselGPL-3.0Sans télémétrie
@@ -236,6 +236,7 @@
Voyez comment les agents IA se servent du réseau
une barrière contre ceux auxquels vous n’en faites pas — voyez le modèle de menaces.
Cursor ⇥ raw.githubusercontent.com · refusé 4 fois · en semaine 18:00–09:00
+
⛔ Sur activation
Packs gérés de publicité et de traqueurs
Trois packs officiels sont intégrés, visibles et désactivés par défaut. Flowlight consulte un flux HTTPS fixe unique pour rechercher les mises à jour signées ; tout contenu nouveau ou modifié reste inappliqué jusqu’à ce que vous examiniez cette version exacte et appuyiez sur Enregistrer. Pendant une panne, le contenu vérifié et accepté peut être conservé comme dernière version valide connue, visiblement signalée comme obsolète.
Ils bloquent par nom d’hôte et ne peuvent donc pas supprimer les publicités internes au site, les vides dans le DOM ou la mise en page, les superpositions du navigateur, les fenêtres contextuelles générées par des scripts ni les fenêtres d’applications natives.
⛔ Refuse
Des garde-fous sur les outils d’un agent
@@ -435,7 +436,7 @@
Un moniteur que vous n’avez pas à surveiller
Reste sur votre Mac
Un seul fichier SQLite local. Pas de compte, pas de synchronisation cloud, pas de télémétrie.
Des métadonnées par défaut
Noms d’hôte, ports et nombre d’octets. Déchiffrer le HTTPS est un mode distinct que vous devez activer, et qui reste sur votre Mac.
Filtré dans le noyau
La capture ne laisse passer que les réponses DNS et les ClientHello TLS. Tout le reste est écarté avant que Flowlight le voie.
-
Des recherches que vous maîtrisez
Nommer le propriétaire d’une adresse n’envoie à Team Cymru que des IP publiques sans nom d’hôte, et se désactive en un clic. La recherche quotidienne de mise à jour ne demande à GitHub que la dernière version, et peut aussi être désactivée.
+
Des recherches que vous maîtrisez
Nommer le propriétaire d’une adresse n’envoie à Team Cymru que les IP publiques sans nom d’hôte. Les vérifications de version et de packs de règles signés envoient des requêtes HTTPS standard sans trafic ni réglages de packs. Chacune dispose de son propre interrupteur.
diff --git a/docs/fr/privacy/index.html b/docs/fr/privacy/index.html
index 3829d0ee..642647a8 100644
--- a/docs/fr/privacy/index.html
+++ b/docs/fr/privacy/index.html
@@ -48,7 +48,7 @@
-
+
Aller au contenu
@@ -125,6 +125,7 @@
Les requêtes réseau que fait l’app
Requête
Envoyée à
Ce qu’elle contient
Quand
Recherche de mise à jour
api.github.com
Une requête HTTPS standard pour la dernière version ; GitHub voit votre adresse IP et l’agent utilisateur Flowlight/<version>
Au lancement et une fois par jour, depuis 0.1.2. Interrupteur d’arrêt dans Réglages › Général
Téléchargement de mise à jour
github.com
L’image disque et son fichier de somme de contrôle
Seulement quand vous cliquez sur Télécharger & ouvrir
+
Mise à jour du pack de règles officiel
flowlight.xinbetween.com
Une requête HTTPS standard vers le flux officiel fixe. Le serveur voit votre adresse IP, l’agent utilisateur Flowlight/<version> et les validateurs conditionnels du cache ; il ne reçoit ni trafic, ni réglages des packs, ni identifiant d’appareil
Après le démarrage, lorsqu’une vérification est due, puis au maximum une fois par jour tant que le service fonctionne normalement. Activée par défaut ; interrupteur de désactivation et option manuelle Rechercher maintenant dans Règles › Packs
DNS inverse
Le résolveur DNS que vous avez configuré
Une recherche du nom enregistré d’une adresse IP, quand aucun nom d’hôte n’a été observé
Au besoin, dans le mode de capture par échantillonnage, qui est celui par défaut jusqu’à ce que l’extension réseau tourne. Pas d’interrupteur d’arrêt : sans elle, l’échantillonneur ne peut pas nommer la plupart des connexions. L’extension réseau les nomme à partir du trafic lui-même et ne fait pas cette recherche
Recherche du propriétaire de réseau
Team Cymru, par DNS
Des adresses IP publiques sans nom d’hôte connu. Les adresses privées et réservées ne quittent jamais votre Mac
Activé par défaut. Interrupteur d’arrêt dans Capture › Identifier les propriétaires de réseau
Demander avec un modèle hébergé
Le fournisseur que vous choisissez : Anthropic, OpenAI, Google Gemini ou un point de terminaison compatible
Votre question et les résultats renvoyés par les requêtes Flowlight en lecture seule choisies par le modèle. La base de données de trafic elle-même n’est pas envoyée
Seulement quand vous choisissez un fournisseur hébergé et soumettez une question. L’écran Demander montre les requêtes transmises
diff --git a/docs/fr/threat-model/index.html b/docs/fr/threat-model/index.html
index a7b6e757..6adb0c2d 100644
--- a/docs/fr/threat-model/index.html
+++ b/docs/fr/threat-model/index.html
@@ -48,7 +48,7 @@
-
+
Aller au contenu
@@ -172,19 +172,13 @@
Ce que Flowlight lui-même conserve
conserve aussi les corps des requêtes et des réponses pendant trois jours — la chose la plus sensible qu’il garde jamais. Les en-têtes
d’identification sont masqués par nom et par mot, ce qui est une heuristique et non une garantie ; un en-tête qui porte un secret sous un
nom que rien ne reconnaît peut être enregistré.
-
La base de données de trafic n’est jamais téléversée. Trois choses plus petites quittent bien le Mac sans qu’on les demande, toutes trois
- nommées sur la page Confidentialité : la recherche de mise à jour, qui demande à GitHub la dernière version
- au lancement et une fois par jour ; la consultation du propriétaire du réseau, qui interroge Team Cymru sur les adresses publiques sans
- nom d’hôte ; et, tant que Flowlight lit des compteurs au lieu de faire tourner son extension, une recherche DNS inverse des adresses dont
- il n’a vu aucun nom. Les deux premières ont un interrupteur d’arrêt. La troisième non, parce que sur l’échantillonneur c’est l’essentiel de la
- façon dont une connexion obtient un nom : elle va au résolveur que le Mac utilise déjà, et elle s’arrête dès que l’extension prend le
- relais et nomme les connexions à partir du trafic lui-même. Demander avec un modèle hébergé envoie bien plus que n’importe laquelle d’entre
- elles, et c’est la seule chose qui reste désactivée jusqu’à ce que vous la configuriez.
+
La base de données de trafic n’est jamais téléversée. La page Confidentialité répertorie chaque requête sortante : vérifications de version et de packs de règles signés, consultation facultative du propriétaire du réseau et DNS inverse avec l’échantillonneur. Les trois premières ont des interrupteurs ; le DNS inverse non, car sans lui l’échantillonneur ne pourrait pas nommer la plupart des connexions. Un modèle hébergé envoie bien plus et reste désactivé jusqu’à configuration.
Mises à jour
Les versions sont signées avec un Developer ID et notarisées. Le programme de mise à jour exige une somme de contrôle publiée et refuse un
téléchargement qui n’en a pas, puis vérifie que l’app téléchargée est signée par la même équipe que la copie en cours d’exécution avant de
l’installer. Une mise à jour signée par quelqu’un d’autre est refusée, même si elle est valide par ailleurs.
+
Les packs de règles officiels utilisent une signature distincte limitée à cet usage et une source HTTPS fixe unique. L’application conserve séparément un repère de la séquence la plus élevée, revérifie au démarrage les octets exacts mis en cache, rejette les retours en arrière et les publications contradictoires, et ne considère jamais la signature d’un éditeur comme une autorisation d’appliquer un contenu modifié. Les packs nouveaux ou modifiés exigent l’examen explicite du contenu exact et Enregistrer ; le contenu obsolète déjà accepté est étiqueté comme dernière version valide connue, tandis qu’une révocation de confiance ou des octets corrompus entraînent un refus sécurisé.
See your apps' network activity. brew install --cask xinbetween/tap/flowlight
-
v0.15.3macOS 15+UniversalGPL-3.0No telemetry
+
v0.15.4macOS 15+UniversalGPL-3.0No telemetry
@@ -248,7 +248,9 @@
Monitor how AI agents use the network
⛔ Opt-in
Managed ad and tracker packs
Three official packs — Advertising, Cross-site tracking, and Privacy and telemetry — are built in, visible and off by
- default. With the Network Extension, apply one to every app or only selected apps; preview it against retained traffic,
+ default. Flowlight checks one fixed HTTPS feed for signed updates; new or changed content remains out of enforcement until
+ you review that exact version and press Save. During an outage, verified accepted content is visibly stale last-known-good.
+
With the Network Extension, apply one to every app or only selected apps; preview it against retained traffic,
see grouped, explainable activity, and add pack-specific exceptions for an app. Ordinary allow rules remain the universal
escape hatch, global Pause Blocking applies, and essential system traffic stays allowed.
They block by hostname, so they cannot remove first-party ads, DOM or layout gaps, browser overlays, scripted popups, or
@@ -455,7 +457,7 @@
A monitor you don't have to monitor
Stays on your Mac
One local SQLite file. No account, no cloud sync, no telemetry.
Metadata by default
Hostnames, ports and byte counts. Decrypting HTTPS is a separate mode you have to turn on, and it stays on your Mac.
Filtered in the kernel
Capture passes only DNS answers and TLS ClientHellos. Everything else is dropped before Flowlight sees it.
-
Lookups you control
Naming who owns an address sends only public IPs with no hostname to Team Cymru, and turns off in one click. The daily update check only asks GitHub for the latest version, and can be turned off too.
+
Lookups you control
Naming who owns an address sends only public IPs with no hostname to Team Cymru. Release and signed rule-pack checks send standard HTTPS requests with no traffic or pack settings. Each has an off switch.
Cursor ⇥ raw.githubusercontent.com · rifiutata 4 volte · nei giorni feriali 18:00–09:00
+
⛔ Da attivare
Pacchetti gestiti per pubblicità e tracker
Tre pacchetti ufficiali sono integrati, visibili e disattivati per impostazione predefinita. Flowlight controlla un unico feed HTTPS fisso per gli aggiornamenti firmati; i contenuti nuovi o modificati restano esclusi dall’applicazione finché non esamini quella versione esatta e premi Salva. Durante un’interruzione, i contenuti verificati e accettati possono restare disponibili come ultima versione valida nota, visibilmente indicata come obsoleta.
Bloccano in base al nome host, quindi non possono rimuovere pubblicità proprietarie, spazi vuoti nel DOM o nel layout, sovrapposizioni del browser, popup generati da script o finestre native delle app.
⛔ Rifiuta
Protezioni sugli strumenti di un agente
@@ -435,7 +436,7 @@
Un monitoraggio che non devi monitorare
Resta sul tuo Mac
Un solo file SQLite locale. Nessun account, nessuna sincronizzazione nel cloud, nessuna telemetria.
Metadati, per impostazione predefinita
Nomi host, porte e byte conteggiati. Decifrare HTTPS è una modalità separata che devi attivare tu, e resta sul tuo Mac.
Filtrato nel kernel
L'acquisizione lascia passare solo le risposte DNS e i ClientHello TLS. Tutto il resto viene scartato prima che Flowlight lo veda.
-
Ricerche che controlli tu
Per dare un nome al proprietario di un indirizzo vengono inviati a Team Cymru solo gli IP pubblici senza nome host, e si disattiva con un clic. Il controllo giornaliero degli aggiornamenti chiede a GitHub solo l'ultima versione, e anche quello si può disattivare.
+
Ricerche che controlli tu
Per dare un nome al proprietario di un indirizzo vengono inviati a Team Cymru solo gli IP pubblici senza nome host. I controlli della versione e dei pacchetti di regole firmati inviano richieste HTTPS standard senza traffico né impostazioni dei pacchetti. Ciascuno ha il proprio interruttore.
diff --git a/docs/it/privacy/index.html b/docs/it/privacy/index.html
index adf1c035..ffb0cc18 100644
--- a/docs/it/privacy/index.html
+++ b/docs/it/privacy/index.html
@@ -48,7 +48,7 @@
-
+
Vai al contenuto
@@ -125,6 +125,7 @@
Richieste di rete che fa l'app
Richiesta
Inviata a
Cosa contiene
Quando
Controllo aggiornamenti
api.github.com
Una normale richiesta HTTPS per l'ultima versione; GitHub vede il tuo indirizzo IP e lo user agent Flowlight/<version>
All'avvio e una volta al giorno, dalla 0.1.2. Interruttore per disattivarla in Impostazioni › Generali
Download dell'aggiornamento
github.com
L'immagine disco e il suo file di checksum
Solo quando fai clic su Scarica e apri
+
Aggiornamento ufficiale del pacchetto di regole
flowlight.xinbetween.com
Una richiesta HTTPS standard al feed ufficiale fisso. Il server vede il tuo indirizzo IP, lo user agent Flowlight/<version> e i validatori condizionali della cache; non riceve traffico, impostazioni dei pacchetti o identificatori del dispositivo
Dopo l’avvio, quando previsto, e successivamente non più di una volta al giorno durante il normale funzionamento. Attivo per impostazione predefinita; interruttore per disattivarlo e opzione manuale Controlla ora in Regole › Pacchetti
DNS inverso
Il resolver DNS che hai configurato
Una ricerca del nome registrato di un indirizzo IP, quando non è stato rilevato alcun nome host
Quando serve, nella modalità di acquisizione a campionamento, che è quella predefinita finché l'estensione di rete non è in funzione. Nessun interruttore per disattivarla: senza di essa il campionatore non può dare un nome alla maggior parte delle connessioni. L'estensione di rete le nomina dal traffico stesso e non fa questa ricerca
Ricerca del proprietario della rete
Team Cymru, via DNS
Indirizzi IP pubblici senza nome host noto. Gli indirizzi privati e riservati non lasciano mai il tuo Mac
Attiva per impostazione predefinita. Interruttore per disattivarla in Acquisizione › Identifica i proprietari della rete
Chiedi con un modello ospitato
Il provider che selezioni: Anthropic, OpenAI, Google Gemini o un endpoint compatibile
La tua domanda e i risultati restituiti dalle query di sola lettura di Flowlight selezionate dal modello. Il database del traffico non viene caricato
Solo quando selezioni un provider ospitato e invii una domanda. La schermata Chiedi mostra le richieste trasmesse
diff --git a/docs/it/threat-model/index.html b/docs/it/threat-model/index.html
index aad21923..3717f41f 100644
--- a/docs/it/threat-model/index.html
+++ b/docs/it/threat-model/index.html
@@ -48,7 +48,7 @@
-
+
Vai al contenuto
@@ -171,18 +171,13 @@
Cosa conserva Flowlight stesso
richieste e delle risposte per tre giorni — la cosa più delicata che arrivi mai a tenere. Le intestazioni con credenziali vengono oscurate per
nome e per parola, che è un'euristica e non una garanzia; un'intestazione che porta un segreto sotto un nome che nulla riconosce può essere
registrata.
-
Il database del traffico non viene mai caricato. Tre cose più piccole lasciano il Mac senza che nessuno le chieda, tutte e tre nominate nella
- pagina sulla privacy: il controllo degli aggiornamenti, che chiede a GitHub l'ultima versione all'avvio e una
- volta al giorno; la ricerca del proprietario della rete, che chiede a Team Cymru degli indirizzi pubblici senza nome host; e, finché Flowlight
- legge i contatori invece di far girare la sua estensione, una ricerca DNS inversa degli indirizzi per cui non ha visto alcun nome. Le prime due
- hanno un interruttore per disattivarle. La terza no, perché sul campionatore è in gran parte il modo in cui una connessione arriva ad avere un
- nome: va al resolver che il Mac già usa, e si ferma appena l'estensione prende il suo posto e nomina le connessioni dal traffico stesso. Chiedi
- con un modello ospitato invia molto più di ognuna di esse, ed è la sola cosa che resta disattivata finché non la configuri.
+
Il database del traffico non viene mai caricato. La pagina sulla privacy elenca ogni richiesta in uscita: controlli della versione e dei pacchetti di regole firmati, ricerca facoltativa del proprietario della rete e DNS inverso con il campionatore. Le prime tre hanno interruttori; il DNS inverso no, perché senza di esso il campionatore non potrebbe nominare la maggior parte delle connessioni. Un modello ospitato invia molto di più e resta disattivato finché non lo configuri.
Aggiornamenti
Le versioni sono firmate con un Developer ID e notarizzate. L'aggiornamento richiede una checksum pubblicata e rifiuta un download che non ne
ha una, poi verifica che l'app scaricata sia firmata dallo stesso team della copia in esecuzione prima di installarla. Un aggiornamento firmato
da qualcun altro viene rifiutato anche se per il resto è valido.
+
I pacchetti di regole ufficiali usano una firma distinta e limitata a questo scopo e un’unica fonte HTTPS fissa. L’app conserva separatamente un marcatore della sequenza più alta, verifica di nuovo all’avvio gli esatti byte memorizzati nella cache, rifiuta rollback e pubblicazioni discordanti e non considera mai la firma dell’editore un’autorizzazione ad applicare contenuti modificati. I pacchetti nuovi o modificati richiedono l’esame esplicito del contenuto esatto e Salva; i contenuti obsoleti già accettati sono indicati come ultima versione valida nota, mentre la revoca dell’attendibilità o byte danneggiati fanno rifiutare il contenuto per sicurezza.
トラフィックのデータベースがアップロードされることはありません。それでも、頼まなくても Mac から出ていくものが 3 つあり、
- どれもプライバシーのページに挙げてあります。起動時と 1 日 1 回、最新のリリースを GitHub に
- 尋ねるアップデートの確認。ホスト名のない公開アドレスについて Team Cymru に尋ねるネットワーク所有者の照会。そして、
- Flowlight が機能拡張ではなくカウンタを読んでいるあいだ、名前が見つからなかったアドレスの逆引き DNS 照会です。
- はじめの 2 つにはオフのスイッチがあります。3 つめにはありません。サンプラーでは、接続に名前が付く道のほとんどがそれだからです。
- 照会はこの Mac がすでに使っているリゾルバへ行き、機能拡張が引き継いでトラフィック自身から名前を付けるようになれば止まります。
- ホスト型モデルでの質問は、そのどれよりもはるかに多くを送りますが、設定するまではオフのままである唯一のものです。
+
トラフィックのデータベースがアップロードされることはありません。プライバシーのページには、リリースと署名付きルールパックの確認、任意のネットワーク所有者照会、サンプラーの逆引き DNS というすべての送信を記載しています。最初の 3 つにはオフのスイッチがあります。逆引き DNS は、多くの接続に名前を付けるため必要なのでありません。ホスト型モデルはより多くを送信し、設定するまでオフです。
アップデート
リリースは Developer ID で署名され、公証されています。アップデート機能は公開されたチェックサムを要求し、それがないダウンロードを
拒否します。そのうえで、ダウンロードしたアプリが実行中のコピーと同じチームの署名であることを確かめてから、インストールします。
別の誰かが署名したアップデートは、ほかの点で有効であっても拒否されます。
세 가지 공식 팩이 내장되어 있고 처음부터 표시되지만 모두 기본적으로 꺼져 있습니다. Flowlight는 서명된 업데이트가 있는지 하나의 고정 HTTPS 피드를 확인합니다. 새 콘텐츠나 변경된 콘텐츠는 정확한 해당 버전을 검토하고 저장을 누를 때까지 차단 적용에서 제외됩니다. 서비스 중단 중에는 검증되고 승인된 콘텐츠가 오래된 상태임을 명확히 표시한 마지막 정상 버전으로 유지될 수 있습니다.
호스트 이름을 기준으로 차단하므로 자사 광고, DOM이나 레이아웃의 빈 공간, 브라우저 오버레이, 스크립트로 생성된 팝업 또는 네이티브 앱 창은 제거할 수 없습니다.
⛔ 거부
에이전트의 도구에 두는 가드레일
@@ -435,7 +436,7 @@
감시하지 않아도 되는 감시 도구
Mac 안에 머무릅니다
로컬 SQLite 파일 하나. 계정도, 클라우드 동기화도, 텔레메트리도 없습니다.
기본은 메타데이터
호스트 이름과 포트, 바이트 수. HTTPS 복호화는 직접 켜야 하는 별도 모드이며, 그 결과도 Mac 안에 남습니다.
커널에서 걸러냅니다
캡처는 DNS 응답과 TLS ClientHello만 통과시킵니다. 나머지는 Flowlight가 보기 전에 버려집니다.
-
조회는 내가 정합니다
주소의 소유자를 알아낼 때는 호스트 이름이 없는 공용 IP만 Team Cymru로 보내며, 클릭 한 번으로 끌 수 있습니다. 하루 한 번 업데이트 확인은 GitHub에 최신 버전만 물어보고, 이것도 끌 수 있습니다.
+
조회는 내가 정합니다
주소의 소유자를 알아낼 때는 호스트 이름이 없는 공용 IP만 Team Cymru로 보냅니다. 릴리스와 서명된 규칙 팩 확인은 트래픽이나 팩 설정 없이 표준 HTTPS 요청을 보냅니다. 각각 별도로 끌 수 있습니다.
트래픽 메타데이터를 로컬 SQLite 데이터베이스에, 공개된 보관 기간만큼 갖고 있습니다. HTTPS 검사를 켜 두면 요청과 응답의 본문도
3일 동안 갖고 있는데, 이것이 Flowlight가 보관하는 것 중 가장 민감한 것입니다. 인증 정보 헤더는 이름과 낱말로 가려지지만, 그것은
추정 규칙이고 보장이 아닙니다. 아무것도 알아보지 못하는 이름 아래 비밀을 실어 보내는 헤더는 기록될 수 있습니다.
-
트래픽 데이터베이스는 업로드되지 않습니다. 그래도 묻지 않아도 Mac을 떠나는 것이 세 가지 있고, 셋 다
- 개인정보 페이지에 이름이 적혀 있습니다. 실행할 때와 하루에 한 번 GitHub에 최신 릴리스를 묻는
- 업데이트 확인, 호스트 이름이 없는 공개 주소를 Team Cymru에 묻는 네트워크 소유자 조회, 그리고 Flowlight가 확장 대신 카운터를
- 읽고 있는 동안 이름을 보지 못한 주소에 대한 역방향 DNS 조회입니다. 앞의 둘은 끄는 스위치가 있습니다. 셋째는 없습니다.
- 샘플러에서는 연결에 이름이 붙는 길이 대부분 그것이기 때문입니다. 그 조회는 Mac이 이미 쓰는 리졸버로 가고, 확장이 이어받아
- 트래픽 자체에서 이름을 붙이기 시작하면 멈춥니다. 호스팅 모델로 하는 질문은 그 셋 가운데 어느 것보다도 훨씬 많은 것을
- 보내지만, 직접 설정할 때까지 꺼져 있는 유일한 것입니다.
+
트래픽 데이터베이스는 업로드되지 않습니다. 개인정보 페이지에는 릴리스와 서명된 규칙 팩 확인, 선택 사항인 네트워크 소유자 조회, 샘플러의 역방향 DNS 등 모든 외부 요청이 나열됩니다. 앞의 세 항목에는 끄기 스위치가 있습니다. 역방향 DNS는 대부분의 연결에 이름을 붙이는 데 필요해 스위치가 없습니다. 호스팅 모델은 훨씬 더 많은 정보를 보내며 설정하기 전에는 꺼져 있습니다.
업데이트
릴리스는 Developer ID로 서명되고 공증됩니다. 업데이터는 공개된 체크섬을 요구하고 그것이 없는 내려받기를 거부하며, 그다음 설치
전에 내려받은 앱이 실행 중인 사본과 같은 팀의 서명인지 확인합니다. 다른 사람이 서명한 업데이트는 나머지가 다 유효해도
거부됩니다.
+
공식 규칙 팩은 용도를 한정한 별도의 서명과 하나의 고정 HTTPS 소스를 사용합니다. 앱은 독립적인 최고 시퀀스 워터마크를 유지하고, 시작할 때 캐시된 정확한 바이트를 다시 검증하며, 롤백과 서로 상충하는 콘텐츠의 제시를 거부합니다. 또한 게시자 서명을 변경된 콘텐츠의 차단 적용을 허용하는 권한으로 취급하지 않습니다. 새 팩이나 변경된 팩에는 정확한 콘텐츠를 명시적으로 검토하고 저장하는 절차가 필요합니다. 오래된 승인 콘텐츠에는 마지막 정상 버전이라는 표시가 붙으며, 신뢰가 취소되었거나 바이트가 손상된 경우에는 안전을 위해 거부됩니다.
diff --git a/docs/llms-full.txt b/docs/llms-full.txt
index 2186f98f..9dff7033 100644
--- a/docs/llms-full.txt
+++ b/docs/llms-full.txt
@@ -62,7 +62,7 @@ Documentation
Using Flowlight
- Everything from the first launch to tuning the agent rules. Flowlight 0.15.3, macOS 15 or later.
+ Everything from the first launch to tuning the agent rules. Flowlight 0.15.4, macOS 15 or later.
On this page
@@ -518,7 +518,12 @@ open build/Build/Products/Release/Flowlight.app
advertising delivery and auction hosts; Cross-site tracking covers common analytics, session replay and
cross-site measurement hosts; and Privacy and telemetry covers crash reporting, install attribution, and
audited Apple advertising and analytics hosts. The packs are visible from the start and all are off by default.
- Nothing changes on an update until you choose a pack and enable it.
+
+ Official-only, signed updates. Flowlight checks one fixed HTTPS source for newer publisher-signed catalogs.
+ Automatic checks are on by default and can be switched off here. A signature authenticates content but does not enable it:
+ a new pack stays off, and changed destinations remove an enabled pack from enforcement until you review the exact new version
+ and press Save. If content changes while its editor is open, the stale Save is refused. An accepted catalog may remain available
+ as visibly stale last-known-good during an outage; rollback, equivocation, revoked trust, or corrupt cached bytes fail closed.
Each pack has its own scope. Apply it globally to every app, or only to selected applications. Blocking needs
the Network Extension: the nettop sampler can record traffic, but it cannot sit in a connection's path and refuse it. Essential
@@ -838,9 +843,9 @@ open build/Build/Products/Release/Flowlight.app
It shows which extensions are installed, whether macOS actually started Flowlight's filter, and how recently anything was recorded.
Paste it into an issue.
Does Flowlight send anything about me anywhere?
- No account, no telemetry, no analytics, and your traffic database is never uploaded. Three requests do leave the Mac, all listed on the
- privacy page: the daily update check and the optional network-owner lookup, both with off switches, and
- reverse DNS while Flowlight is on the sampler, which has none — without it the sampler could not name most connections.
+ No account, no telemetry, no analytics, and your traffic database is never uploaded. Four requests can leave the Mac, all listed on the
+ privacy page: release and signed rule-pack checks plus the optional network-owner lookup, all with off
+ switches, and reverse DNS while Flowlight is on the sampler, which has none — without it the sampler could not name most connections.
Will it slow my Mac down or drain the battery?
With the window closed Flowlight does almost nothing: it keeps the menu bar rates and writes one row a second. The Network Extension
costs less than the fallback sampler, which starts a short-lived nettop process every second.
@@ -887,7 +892,7 @@ Understand your AI agents.
brew install --cask xinbetween/tap/flowlightCopy
- v0.15.3macOS 15+UniversalGPL-3.0No telemetry
+ v0.15.4macOS 15+UniversalGPL-3.0No telemetry
connectionslive
@@ -1038,7 +1043,10 @@ Optional. What did the agent actually send, and which tool call made it? Decrypt
Managed ad and tracker packs
Three official packs — Advertising, Cross-site tracking, and Privacy and telemetry — are built in, visible and off by
- default. With the Network Extension, apply one to every app or only selected apps; preview it against retained traffic,
+ default. Flowlight checks one fixed HTTPS feed for signed updates; new or changed content remains out of enforcement until
+ you review that exact version and press Save. During an outage, verified accepted content is visibly stale last-known-good.
+
+ With the Network Extension, apply one to every app or only selected apps; preview it against retained traffic,
see grouped, explainable activity, and add pack-specific exceptions for an app. Ordinary allow rules remain the universal
escape hatch, global Pause Blocking applies, and essential system traffic stays allowed.
@@ -1256,7 +1264,7 @@ e.g. Wireshark
Filtered in the kernelCapture passes only DNS answers and TLS ClientHellos. Everything else is dropped before Flowlight sees it.
- Lookups you controlNaming who owns an address sends only public IPs with no hostname to Team Cymru, and turns off in one click. The daily update check only asks GitHub for the latest version, and can be turned off too.
+ Lookups you controlNaming who owns an address sends only public IPs with no hostname to Team Cymru. Release and signed rule-pack checks send standard HTTPS requests with no traffic or pack settings. Each has an off switch.
One agent, every tool call. Traffic from the git, curl and MCP servers Claude Code started counts as Claude Code's.
With HTTPS inspection on, the Tool calls tab shows what the model asked for, what came back, and the requests each tool made:
@@ -1569,6 +1577,8 @@ Privacy
Update downloadgithub.comThe disk image and its checksum fileOnly when you click Download & Open
+ Official rule-pack updateflowlight.xinbetween.comA standard HTTPS request to the fixed official feed for a publisher-signed catalog. The server sees your IP address, the Flowlight/ user agent, and conditional cache validators; it receives no traffic, pack settings, or device identifierAfter startup when due and then at most daily while healthy. On by default; off switch and manual Check Now in Rules › Packs
+
Reverse DNSYour configured DNS resolverA lookup of an IP address's registered name, when no hostname was seenAs needed, in the sampler capture mode, which is the default until the Network Extension is running. No off switch: without it the sampler cannot name most connections. The Network Extension names them from the traffic itself and does not do this
Network owner lookupTeam Cymru, over DNSPublic IP addresses with no known hostname. Private and reserved addresses never leave your MacOn by default. Off switch in Capture › Identify network owners
@@ -1617,8 +1627,27 @@ Releases
Downloads, checksums and full notes for each version are on GitHub Releases.
+ 0.15.4
+October 20, 2026Latest
+
+ Consume verified official rule-pack updates. Flowlight checks its one fixed HTTPS feed with conditional
+ requests, verifies publisher signatures and compatibility before persistence, and keeps exact last-known-good bytes plus
+ an independent anti-rollback watermark.
+
+ Keep every policy change consentful. New packs stay off. Changed versions or destinations immediately leave
+ enforcement while preserving scope, selected apps, exceptions and history; explicit review and Save of the exact new content
+ restores consent, and a refresh racing an open editor makes its old Save stale.
+
+ Expose trust and failure state without overclaiming. Rules › Packs now shows automatic-refresh controls,
+ verification status, sequence, source, signer, timestamps, bundled-versus-downloaded provenance, stale last-known-good, and
+ independent fetch/apply failures. Refresh sends no traffic, pack settings or device identifier.
+
+ Fail closed at startup and on the wire. Capture waits for catalog verification plus saved exceptions;
+ redirects, MIME, size, timeout, rollback, equivocation, revoked trust, corrupt cache and failed database commits cannot publish
+ unseen definitions or replace the active snapshot.
+
0.15.3
-October 19, 2026Latest
+October 19, 2026
Publish official rule packs as signed data. Flowlight now defines a purpose-scoped P-256
signature contract for its official managed-pack catalog, with the public verification key included in the
@@ -2996,13 +3025,12 @@ Threat model
response bodies for three days — the most sensitive thing it ever keeps. Credential headers are redacted by name and by word,
which is a heuristic and not a guarantee; a header carrying a secret under a name nothing recognises can be recorded.
- The traffic database is never uploaded. Three smaller things do leave the Mac without being asked for, all of them named on
- the privacy page: the update check, which asks GitHub for the latest release on launch and
- daily; the network-owner lookup, which asks Team Cymru about public addresses that have no hostname; and, while Flowlight is
- reading counters instead of running its extension, a reverse-DNS lookup of addresses it saw no name for. The first two have
- off switches. The third does not, because on the sampler it is most of how a connection gets a name at all — it goes to the
- resolver the Mac already uses, and it stops once the extension takes over and names connections from the traffic itself.
- Ask with a hosted model sends far more than any of them, and is the one thing that stays off until you configure it.
+ The traffic database is never uploaded. Four smaller things do leave the Mac without being asked for, all of them named on
+ the privacy page: the release update check; the fixed official rule-pack feed, which receives
+ no traffic or pack settings; the network-owner lookup, which asks Team Cymru about public addresses with no hostname; and,
+ while Flowlight is reading counters instead of running its extension, reverse DNS through the Mac's configured resolver.
+ The first three have off switches. Reverse DNS does not, because on the sampler it is most of how a connection gets a name;
+ it stops once the extension takes over. Ask with a hosted model sends far more than any of them, and stays off until configured.
Updates
@@ -3010,6 +3038,11 @@ Threat model
without one, then verifies that the downloaded app is signed by the same team as the running copy before installing it. An
update signed by somebody else is refused even if it is otherwise valid.
+ Official rule packs use a separate purpose-scoped P-256 signature and one compiled HTTPS source. The app keeps an independent
+ highest-sequence watermark, reverifies exact cached bytes at launch, rejects rollback and equivocation, and never treats a
+ publisher signature as permission to enforce changed content. New or changed packs require explicit exact-content review and
+ Save; stale accepted content is labeled last-known-good, while revoked trust or corrupt bytes fail closed.
+
Reporting a vulnerability
Use GitHub's private vulnerability reporting, or email
diff --git a/docs/llms.txt b/docs/llms.txt
index 939c0eb5..63570739 100644
--- a/docs/llms.txt
+++ b/docs/llms.txt
@@ -1,6 +1,6 @@
# Flowlight
-> Free, open-source (GPL-3.0) application-aware network monitor for macOS, with focused visibility into AI agents. It attributes observed TCP and UDP activity to the application that made it and records the destination, protocol and byte counts, keeping local history from second to year. Recognized AI agents are listed by name along with the tools and MCP servers they start, under per-agent allowlists. It can also refuse, once asked: a rule blocks an application, a destination or a URL for as long as you specify, and a guardrail withholds a tool from an agent before its model is offered it. Runs on macOS 15 or later; capture is by a sampler or a Network Extension. Current version: 0.15.3.
+> Free, open-source (GPL-3.0) application-aware network monitor for macOS, with focused visibility into AI agents. It attributes observed TCP and UDP activity to the application that made it and records the destination, protocol and byte counts, keeping local history from second to year. Recognized AI agents are listed by name along with the tools and MCP servers they start, under per-agent allowlists. It can also refuse, once asked: a rule blocks an application, a destination or a URL for as long as you specify, and a guardrail withholds a tool from an agent before its model is offered it. Runs on macOS 15 or later; capture is by a sampler or a Network Extension. Current version: 0.15.4.
- [Download Flowlight.dmg](https://github.com/xinbetween/flowlight/releases/latest/download/Flowlight.dmg)
- [Source code](https://github.com/xinbetween/flowlight)
diff --git a/docs/plugins/index.html b/docs/plugins/index.html
index 305efd70..25276876 100644
--- a/docs/plugins/index.html
+++ b/docs/plugins/index.html
@@ -211,7 +211,7 @@
A standard HTTPS request for the latest release; GitHub sees your IP address and the Flowlight/<version> user agent
After a clean quit and relaunch, and daily. Off switch in Settings › General
Update download
github.com
The disk image and its checksum file
Only when you click Download & Open
+
Official rule-pack update
flowlight.xinbetween.com
A standard HTTPS request to the fixed official feed for a publisher-signed catalog. The server sees your IP address, the Flowlight/<version> user agent, and conditional cache validators; it receives no traffic, pack settings, or device identifier
After startup when due and then at most daily while healthy. On by default; off switch and manual Check Now in Rules › Packs
Reverse DNS
Your configured DNS resolver
A lookup of an IP address's registered name, when no hostname was seen
As needed, in the sampler capture mode, which is the default until the Network Extension is running. No off switch: without it the sampler cannot name most connections. The Network Extension names them from the traffic itself and does not do this
Network owner lookup
Team Cymru, over DNS
Public IP addresses with no known hostname. Private and reserved addresses never leave your Mac
On by default. Off switch in Capture › Identify network owners
Ask with a hosted model
The provider you select: Anthropic, OpenAI, Google Gemini or a compatible endpoint
Your question and the results returned by the read-only Flowlight queries selected by the model. The traffic database itself is not uploaded
Only when you select a hosted provider and submit a question. The Ask screen shows the transmitted requests
Cursor ⇥ raw.githubusercontent.com · recusado 4 vezes · dias de semana 18:00–09:00
+
⛔ Por opção
Pacotes geridos de publicidade e rastreadores
Estão integrados três pacotes oficiais, visíveis e desativados por predefinição. O Flowlight consulta um único feed HTTPS fixo para obter atualizações assinadas; os conteúdos novos ou alterados ficam fora da aplicação até rever essa versão exata e premir Guardar. Durante uma indisponibilidade, os conteúdos verificados e aceites podem continuar disponíveis como a última versão válida conhecida, visivelmente assinalada como desatualizada.
Bloqueiam por nome de anfitrião, pelo que não conseguem remover anúncios próprios, espaços vazios no DOM ou no esquema da página, sobreposições do navegador, janelas emergentes criadas por scripts nem janelas nativas das aplicações.
⛔ Recusa
Proteções nas ferramentas de um agente
@@ -435,7 +436,7 @@
Um monitor que não tem de monitorizar
Fica no seu Mac
Um único ficheiro SQLite local. Sem conta, sem sincronização na nuvem, sem telemetria.
Metadados por predefinição
Nomes de anfitrião, portas e contagens de bytes. Desencriptar HTTPS é um modo separado que tem de ativar, e fica tudo no seu Mac.
Filtrado no núcleo
A captura só deixa passar as respostas de DNS e os ClientHello de TLS. Todo o resto é descartado antes de o Flowlight o ver.
-
Consultas sob o seu controlo
Saber quem é o proprietário de um endereço envia à Team Cymru apenas IPs públicos sem nome de anfitrião, e desativa-se num clique. A procura diária de atualizações só pergunta ao GitHub pela versão mais recente e também pode ser desativada.
+
Consultas sob o seu controlo
Saber quem é o proprietário de um endereço envia à Team Cymru apenas IPs públicos sem nome de anfitrião. As verificações de versões e de pacotes de regras assinados enviam pedidos HTTPS normais, sem tráfego nem definições dos pacotes. Cada uma tem o seu próprio interruptor.
diff --git a/docs/pt-PT/privacy/index.html b/docs/pt-PT/privacy/index.html
index e2cff2c4..452c2b6e 100644
--- a/docs/pt-PT/privacy/index.html
+++ b/docs/pt-PT/privacy/index.html
@@ -48,7 +48,7 @@
-
+
Ir para o conteúdo
@@ -125,6 +125,7 @@
Pedidos de rede que a app faz
Pedido
Enviado para
O que contém
Quando
Procura de atualizações
api.github.com
Um pedido HTTPS normal pela versão mais recente; o GitHub vê o seu endereço IP e o agente de utilizador Flowlight/<version>
Na abertura e uma vez por dia, a partir da 0.1.2. Interruptor para desativar em Definições › Geral
Transferência da atualização
github.com
A imagem de disco e o seu ficheiro de soma de verificação
Só quando clica em Transferir e abrir
+
Atualização oficial do pacote de regras
flowlight.xinbetween.com
Um pedido HTTPS padrão ao feed oficial fixo. O servidor vê o seu endereço IP, o agente do utilizador Flowlight/<version> e os validadores condicionais de cache; não recebe tráfego, definições dos pacotes nem qualquer identificador do dispositivo
Após o arranque, quando for necessário, e depois, no máximo, uma vez por dia enquanto funcionar normalmente. Ativa por predefinição; opção para desativar e Procurar agora manualmente em Regras › Pacotes
DNS inverso
O resolvedor de DNS que tem configurado
Uma consulta do nome registado de um endereço IP, quando não foi visto nenhum nome de anfitrião
Conforme necessário, no modo de captura por amostrador, que é a predefinição até a extensão de rede estar a correr. Sem interruptor para desligar: sem isto, o amostrador não consegue dar nome à maioria das ligações. A extensão de rede dá-lhes nome a partir do próprio tráfego e não faz esta consulta
Consulta do proprietário da rede
Team Cymru, por DNS
Endereços IP públicos sem nome de anfitrião conhecido. Os endereços privados e reservados nunca saem do seu Mac
Ativada por predefinição. Interruptor para desativar em Captura › Identificar os proprietários da rede
Perguntar com um modelo alojado
O fornecedor que escolher: Anthropic, OpenAI, Google Gemini ou um endpoint compatível
A sua pergunta e os resultados devolvidos pelas consultas de leitura do Flowlight escolhidas pelo modelo. A base de dados de tráfego em si não é enviada
Só quando escolhe um fornecedor alojado e submete uma pergunta. O ecrã Perguntar mostra os pedidos transmitidos
diff --git a/docs/pt-PT/threat-model/index.html b/docs/pt-PT/threat-model/index.html
index 8f6d8d32..1d06fc0c 100644
--- a/docs/pt-PT/threat-model/index.html
+++ b/docs/pt-PT/threat-model/index.html
@@ -48,7 +48,7 @@
-
+
Ir para o conteúdo
@@ -171,19 +171,13 @@
O que o próprio Flowlight guarda
pedidos e das respostas durante três dias — a coisa mais sensível que chega a manter. Os cabeçalhos de credenciais são ocultados por nome e
por palavra, o que é uma heurística e não uma garantia; um cabeçalho que leve um segredo sob um nome que nada reconhece pode ficar
registado.
-
A base de dados de tráfego nunca é enviada. Há, ainda assim, três coisas mais pequenas que saem do Mac sem lhe serem pedidas,
- todas nomeadas na página de privacidade: a procura de atualizações, que pede ao GitHub a versão mais
- recente no arranque e uma vez por dia; a consulta do proprietário da rede, que pergunta ao Team Cymru por endereços públicos sem
- nome de anfitrião; e, enquanto o Flowlight lê contadores em vez de correr a sua extensão, uma consulta de DNS inverso dos endereços
- para os quais não viu nome. As duas primeiras têm interruptores para desligar. A terceira não tem, porque no amostrador é quase
- tudo aquilo que dá um nome a uma ligação — vai para o resolvedor de DNS que o Mac já usa e para quando a extensão assume e passa a
- nomear as ligações a partir do próprio tráfego. Perguntar com um modelo alojado envia muito mais do que qualquer uma delas, e é a
- única coisa que fica desligada até a configurar.
+
A base de dados de tráfego nunca é enviada. A página de privacidade lista cada pedido externo: verificações de versão e de pacotes de regras assinados, consulta opcional do proprietário da rede e DNS inverso com o amostrador. Os três primeiros têm interruptores; o DNS inverso não, porque sem ele o amostrador não conseguiria nomear a maioria das ligações. Um modelo alojado envia muito mais e fica desligado até o configurar.
Atualizações
As versões são assinadas com um Developer ID e notarizadas. O atualizador exige uma soma de verificação publicada e recusa uma
transferência sem ela; depois verifica que a app transferida está assinada pela mesma equipa que a cópia em execução antes de a instalar. Uma
atualização assinada por outra pessoa é recusada mesmo que no resto seja válida.
+
Os pacotes de regras oficiais usam uma assinatura distinta e limitada a esta finalidade e uma única fonte HTTPS fixa. A aplicação mantém separadamente um marcador da sequência mais alta, volta a verificar no arranque os bytes exatos guardados em cache, rejeita regressões e publicações contraditórias e nunca trata a assinatura do editor como autorização para aplicar conteúdos alterados. Os pacotes novos ou alterados exigem a revisão explícita do conteúdo exato e Guardar; os conteúdos desatualizados já aceites são assinalados como a última versão válida conhecida, enquanto a revogação da confiança ou bytes corrompidos fazem com que o conteúdo seja recusado por segurança.
Consume verified official rule-pack updates. Flowlight checks its one fixed HTTPS feed with conditional
+ requests, verifies publisher signatures and compatibility before persistence, and keeps exact last-known-good bytes plus
+ an independent anti-rollback watermark.
+
Keep every policy change consentful. New packs stay off. Changed versions or destinations immediately leave
+ enforcement while preserving scope, selected apps, exceptions and history; explicit review and Save of the exact new content
+ restores consent, and a refresh racing an open editor makes its old Save stale.
+
Expose trust and failure state without overclaiming. Rules › Packs now shows automatic-refresh controls,
+ verification status, sequence, source, signer, timestamps, bundled-versus-downloaded provenance, stale last-known-good, and
+ independent fetch/apply failures. Refresh sends no traffic, pack settings or device identifier.
+
Fail closed at startup and on the wire. Capture waits for catalog verification plus saved exceptions;
+ redirects, MIME, size, timeout, rollback, equivocation, revoked trust, corrupt cache and failed database commits cannot publish
+ unseen definitions or replace the active snapshot.
+
+
+
+
+
0.15.3
Publish official rule packs as signed data. Flowlight now defines a purpose-scoped P-256
signature contract for its official managed-pack catalog, with the public verification key included in the
@@ -1274,7 +1292,7 @@
Traffic metadata in a local SQLite database, with published retention. With HTTPS inspection on it also holds request and
response bodies for three days — the most sensitive thing it ever keeps. Credential headers are redacted by name and by word,
which is a heuristic and not a guarantee; a header carrying a secret under a name nothing recognises can be recorded.
-
The traffic database is never uploaded. Three smaller things do leave the Mac without being asked for, all of them named on
- the privacy page: the update check, which asks GitHub for the latest release on launch and
- daily; the network-owner lookup, which asks Team Cymru about public addresses that have no hostname; and, while Flowlight is
- reading counters instead of running its extension, a reverse-DNS lookup of addresses it saw no name for. The first two have
- off switches. The third does not, because on the sampler it is most of how a connection gets a name at all — it goes to the
- resolver the Mac already uses, and it stops once the extension takes over and names connections from the traffic itself.
- Ask with a hosted model sends far more than any of them, and is the one thing that stays off until you configure it.
+
The traffic database is never uploaded. Four smaller things do leave the Mac without being asked for, all of them named on
+ the privacy page: the release update check; the fixed official rule-pack feed, which receives
+ no traffic or pack settings; the network-owner lookup, which asks Team Cymru about public addresses with no hostname; and,
+ while Flowlight is reading counters instead of running its extension, reverse DNS through the Mac's configured resolver.
+ The first three have off switches. Reverse DNS does not, because on the sampler it is most of how a connection gets a name;
+ it stops once the extension takes over. Ask with a hosted model sends far more than any of them, and stays off until configured.
Updates
Releases are signed with a Developer ID and notarized. The updater requires a published checksum and refuses a download
without one, then verifies that the downloaded app is signed by the same team as the running copy before installing it. An
update signed by somebody else is refused even if it is otherwise valid.
+
Official rule packs use a separate purpose-scoped P-256 signature and one compiled HTTPS source. The app keeps an independent
+ highest-sequence watermark, reverifies exact cached bytes at launch, rejects rollback and equivocation, and never treats a
+ publisher signature as permission to enforce changed content. New or changed packs require explicit exact-content review and
+ Save; stale accepted content is labeled last-known-good, while revoked trust or corrupt bytes fail closed.
diff --git a/scripts/check_site.py b/scripts/check_site.py
index a3a7a398..4628f770 100644
--- a/scripts/check_site.py
+++ b/scripts/check_site.py
@@ -22,6 +22,7 @@
OFFICIAL_PACK_OUTPUT = OUT / "packs/v1/official.json"
OFFICIAL_PACK_PUBLIC_KEY = ROOT / "Flowlight/Resources/RulePacks/official-rule-packs-v1-public.pem"
OFFICIAL_PACK_KEY_ID = "flowlight-rule-packs-2026-01"
+OFFICIAL_PACK_URL = "https://flowlight.xinbetween.com/packs/v1/official.json"
PROJECT = (ROOT / "project.yml").read_text()
APP_VERSION = re.search(r'MARKETING_VERSION:\s*"([^"]+)"', PROJECT).group(1)
@@ -90,6 +91,40 @@ def problems():
detail = (verified.stderr or verified.stdout).strip().replace("\n", " ")
found.append(f"site/packs/v1/official.json: signature verification failed: {detail}")
+ # The app now consumes this endpoint. Keep the fixed source, privacy disclosure, documentation and release notes
+ # tied together so a future refactor cannot silently create an undocumented outbound request or arbitrary source.
+ verifier_source = (ROOT / "Flowlight/Analysis/RulePackCatalogVerifier.swift").read_text()
+ if OFFICIAL_PACK_URL not in verifier_source:
+ found.append("Flowlight/Analysis/RulePackCatalogVerifier.swift: fixed official feed URL is missing")
+ source_pages = [ROOT / "README.md", ROOT / "SECURITY.md", ROOT / "site/pages/docs.html",
+ ROOT / "site/pages/privacy.html", ROOT / "site/pages/threat-model.html"]
+ for path in source_pages:
+ text = path.read_text().lower()
+ if "official" not in text or not any(word in text for word in ("signed", "signature", "publisher")):
+ found.append(f"{path.relative_to(ROOT)}: authenticated official rule-pack consumer is not documented")
+ privacy_sources = [ROOT / "site/pages/privacy.html"] + [
+ ROOT / f"site/pages/{lang}/privacy.html" for lang in LANGS
+ ]
+ for path in privacy_sources:
+ if "flowlight.xinbetween.com" not in path.read_text():
+ found.append(f"{path.relative_to(ROOT)}: official rule-pack request is missing from privacy table")
+ docs_sources = [ROOT / "site/pages/docs.html"] + [
+ ROOT / f"site/pages/{lang}/docs.html" for lang in LANGS
+ ]
+ for path in docs_sources:
+ if 'id="managed-packs"' not in path.read_text():
+ found.append(f"{path.relative_to(ROOT)}: #managed-packs subscription section is missing")
+ stale_request_counts = ("Three requests", "Three smaller things", "Drei Anfragen", "Drei kleinere Dinge",
+ "Tres peticiones", "Tres cosas más pequeñas", "Trois requêtes", "Trois choses plus petites",
+ "Tre richieste", "Tre cose più piccole", "Três pedidos", "três coisas mais pequenas",
+ "リクエストは 3 つ", "요청은 세 가지", "떠나는 것이 세 가지",
+ "三个请求", "三個請求")
+ for path in [ROOT / "site/pages/docs.html", ROOT / "site/pages/threat-model.html"] + [
+ ROOT / f"site/pages/{lang}/docs.html" for lang in LANGS
+ ] + [ROOT / f"site/pages/{lang}/threat-model.html" for lang in LANGS]:
+ if any(phrase.casefold() in path.read_text().casefold() for phrase in stale_request_counts):
+ found.append(f"{path.relative_to(ROOT)}: fixed outbound-request count is stale-prone")
+
# 1. A placeholder that didn't render. `{{t:nav.threat-model}}` printed itself on all 51 pages because the
# pattern for {{t:}} didn't allow hyphens, and nothing noticed — it is valid HTML.
for page in PAGES:
@@ -140,8 +175,16 @@ def problems():
# newest version on the releases page. It reads as a promise until someone notices it is a memory.
releases = (ROOT / "site/pages/releases.html").read_text()
shipped = re.findall(r"
(\d+\.\d+(?:\.\d+)?)
", releases)
+ release_headers = re.findall(r"(.*?)", releases, re.S)
+ latest = [match.group(1) for header in release_headers
+ if 'class="tag">Latest' in header
+ if (match := re.search(r"
(\d+\.\d+(?:\.\d+)?)
", header))]
if shipped:
newest = max(shipped, key=version_key)
+ if newest != APP_VERSION:
+ found.append(f"site/pages/releases.html: newest release {newest} does not match app {APP_VERSION}")
+ if latest != [APP_VERSION]:
+ found.append(f"site/pages/releases.html: expected one Latest tag on {APP_VERSION}, found {latest}")
for page in PAGES:
text = page.read_text()
start = text.find('id="roadmap"')
diff --git a/site/pages/de/docs.html b/site/pages/de/docs.html
index 061836f8..64432977 100644
--- a/site/pages/de/docs.html
+++ b/site/pages/de/docs.html
@@ -384,6 +384,10 @@
Regeln
Bildschirm nennt außerdem den ältesten Datenverkehr, den er sehen konnte: Eine Regel, die gegen zwei Stunden Aufzeichnung
getestet wurde, ist gegen zwei Stunden Aufzeichnung getestet.
+
Verwaltete Werbe- und Tracker-Pakete
+
Regeln › Pakete enthält drei offizielle Hostnamenlisten: Werbung, Websiteübergreifendes Tracking und Datenschutz und Telemetrie. Sie sind von Anfang an sichtbar und standardmäßig alle deaktiviert. Zum Blockieren ist die Netzwerkerweiterung erforderlich; der nettop-Sampler kann nur beobachten.
+
Ausschließlich offizielle, signierte Updates. Flowlight prüft eine einzige festgelegte HTTPS-Quelle auf neuere, vom Herausgeber signierte Kataloge. Automatische Prüfungen sind standardmäßig aktiviert und können hier deaktiviert werden. Eine Signatur authentifiziert den Inhalt, aktiviert ihn aber nicht: Ein neues Paket bleibt deaktiviert, und bei geänderten Zielen wird ein aktiviertes Paket nicht mehr durchgesetzt, bis du die genaue neue Version geprüft und auf Sichern geklickt hast. Akzeptierte Inhalte können während eines Ausfalls als sichtbar veralteter letzter bekannter funktionsfähiger Stand erhalten bleiben. Dies sind Netzwerksperren auf Hostnamenebene: Sie können keine Lücken im DOM oder Layout, Browser-Overlays, skriptgesteuerten Pop-ups oder Fenster nativer Apps entfernen.
+
Erlaubnislisten
Wähle einen Agenten in KI-Agenten und sage über Erlaubnisliste in seinem Detail, wohin er sich verbinden darf. Trage Domains
(Subdomains inklusive), IP-Adressen oder CIDR-Bereiche ein oder nimm eine Vorlage: GitHub, npm, PyPI, Homebrew, Docker Hub, Rust crates, Go-Module oder
@@ -622,9 +626,7 @@
Fragen & Antworten
Es zeigt, welche Erweiterungen installiert sind, ob macOS Flowlights Filter wirklich gestartet hat und wie kürzlich überhaupt etwas aufgezeichnet wurde.
Füge es in ein Issue ein.
Sendet Flowlight irgendetwas über mich irgendwohin?
-
Kein Konto, keine Telemetrie, keine Analyse, und deine Datenbank mit dem Datenverkehr wird nie hochgeladen. Drei Anfragen verlassen den Mac, alle auf der
- Datenschutzseite aufgelistet: die tägliche Update-Prüfung und die optionale Abfrage zum Netzinhaber, beide abschaltbar, und,
- solange Flowlight auf dem Sampler läuft, Reverse-DNS, für das es keinen Schalter gibt – ohne es könnte der Sampler die meisten Verbindungen nicht benennen.
+
Kein Konto, keine Telemetrie, keine Analyse, und deine Datenbank mit dem Datenverkehr wird nie hochgeladen. Die Datenschutzseite listet jede Anfrage auf: Release- und signierte Regelpaket-Prüfungen sowie die optionale Abfrage zum Netzinhaber haben Abschalter; Reverse-DNS läuft nur mit dem Sampler und hat keinen, weil er sonst die meisten Verbindungen nicht benennen könnte.
Macht es meinen Mac langsamer oder leert es den Akku?
Mit geschlossenem Fenster tut Flowlight fast nichts: Es hält die Raten in der Menüleiste und schreibt eine Zeile pro Sekunde. Die Netzwerkerweiterung
kostet weniger als der Sampler als Rückfallebene, der jede Sekunde einen kurzlebigen nettop-Prozess startet.
Drei offizielle Pakete sind integriert, sichtbar und standardmäßig deaktiviert. Flowlight prüft einen einzigen festgelegten HTTPS-Feed auf signierte Updates; neue oder geänderte Inhalte werden erst durchgesetzt, nachdem du genau diese Version geprüft und Sichern gedrückt hast. Verifizierte, akzeptierte Inhalte können bei einem Ausfall als sichtbar veralteter letzter bekannter funktionsfähiger Stand erhalten bleiben.
Sie blockieren nach Hostname und können daher keine Erstanbieterwerbung, Lücken im DOM oder Layout, Browser-Overlays, skriptgesteuerten Pop-ups oder Fenster nativer Apps entfernen.
⛔ Verweigert
Schutzregeln für die Tools eines Agenten
@@ -366,7 +367,7 @@
Ein Wächter, den du nicht bewachen musst
Bleibt auf deinem Mac
Eine lokale SQLite-Datei. Kein Konto, keine Cloud-Synchronisierung, keine Telemetrie.
Standardmäßig nur Metadaten
Hostnamen, Ports und Byte-Zahlen. HTTPS zu entschlüsseln ist ein eigener Modus, den du einschalten musst – und er bleibt auf deinem Mac.
Im Kernel gefiltert
Die Erfassung lässt nur DNS-Antworten und TLS-ClientHellos durch. Alles andere wird verworfen, bevor Flowlight es sieht.
-
Abfragen, die du steuerst
Um zu benennen, wem eine Adresse gehört, gehen nur öffentliche IPs ohne Hostnamen an Team Cymru – abschaltbar mit einem Klick. Die tägliche Update-Prüfung fragt GitHub nur nach der neuesten Version und lässt sich ebenfalls abschalten.
+
Abfragen, die du steuerst
Um zu benennen, wem eine Adresse gehört, gehen nur öffentliche IPs ohne Hostnamen an Team Cymru. Release- und signierte Regelpaket-Prüfungen senden Standard-HTTPS-Anfragen ohne Verkehrs- oder Paketeinstellungen. Jede Prüfung hat einen eigenen Abschalter.
Eine normale HTTPS-Anfrage nach der neuesten Version; GitHub sieht deine IP-Adresse und den User-Agent Flowlight/<version>
Beim Start und täglich, ab 0.1.2. Abschalten in Einstellungen › Allgemein
Update-Download
github.com
Das Image und seine Prüfsummendatei
Nur wenn du auf „Herunterladen & öffnen“ klickst
+
Offizielle Regelpaket-Aktualisierung
flowlight.xinbetween.com
Eine standardmäßige HTTPS-Anfrage an den festgelegten offiziellen Feed. Der Server sieht deine IP-Adresse, den User-Agent Flowlight/<version> und bedingte Cache-Validatoren; er erhält weder Datenverkehr noch Paketeinstellungen oder eine Gerätekennung
Nach dem Start, sobald eine Prüfung fällig ist, und danach bei störungsfreiem Betrieb höchstens einmal täglich. Standardmäßig aktiviert; Ausschalter und manuelle Option Jetzt suchen unter Regeln › Pakete
Reverse-DNS
Der von dir eingerichtete DNS-Resolver
Eine Abfrage des registrierten Namens einer IP-Adresse, wenn kein Hostname gesehen wurde
Nach Bedarf, im Erfassungsmodus des Samplers, der Standard ist, bis die Netzwerkerweiterung läuft. Kein Schalter zum Abschalten: ohne sie kann der Sampler die meisten Verbindungen nicht benennen. Die Netzwerkerweiterung benennt sie aus dem Datenverkehr selbst und macht diese Abfrage nicht
Abfrage des Netzinhabers
Team Cymru, über DNS
Öffentliche IP-Adressen ohne bekannten Hostnamen. Private und reservierte Adressen verlassen deinen Mac nie
Standardmäßig ein. Abschalten in Erfassung › Netzinhaber bestimmen
Fragen mit einem gehosteten Modell
Der Anbieter, den du wählst: Anthropic, OpenAI, Google Gemini oder ein kompatibler Endpunkt
Deine Frage und die Ergebnisse der nur lesenden Flowlight-Abfragen, die das Modell ausgewählt hat. Die Verkehrsdatenbank selbst wird nicht hochgeladen
Nur wenn du einen gehosteten Anbieter wählst und eine Frage abschickst. Der Bildschirm Fragen zeigt die gesendeten Anfragen
HTTPS-Inspektion außerdem Anfrage- und Antwort-Bodys für drei Tage – das Sensibelste, was es überhaupt aufbewahrt. Header mit Zugangsdaten
werden nach Name und nach Wortbestandteil unlesbar gemacht, was eine Heuristik ist und keine Garantie; ein Header, der ein Geheimnis unter
einem Namen trägt, den nichts erkennt, kann aufgezeichnet werden.
-
Die Datenbank mit dem Datenverkehr wird nie hochgeladen. Drei kleinere Dinge verlassen den Mac, ohne dass jemand danach fragt, alle drei auf der
- Datenschutzseite benannt: die Update-Prüfung, die GitHub beim Start und täglich nach der neuesten Version
- fragt; die Abfrage zum Netzinhaber, die Team Cymru nach öffentlichen Adressen ohne Hostnamen fragt; und, solange Flowlight Zähler liest
- statt seine Erweiterung zu betreiben, eine Reverse-DNS-Abfrage der Adressen, zu denen es keinen Namen gesehen hat. Die ersten beiden haben
- Schalter zum Abschalten. Die dritte nicht, denn auf dem Sampler ist sie fast der einzige Weg, auf dem eine Verbindung überhaupt zu einem
- Namen kommt – sie geht an den Resolver, den der Mac schon benutzt, und sie hört auf, sobald die Erweiterung übernimmt und Verbindungen aus dem
- Datenverkehr selbst benennt. Fragen mit einem gehosteten Modell sendet weit mehr als alle drei und ist das eine, das aus bleibt, bis du es
- einrichtest.
+
Die Datenbank mit dem Datenverkehr wird nie hochgeladen. Die Datenschutzseite nennt jede ausgehende Anfrage: Release- und signierte Regelpaket-Prüfungen, die optionale Abfrage zum Netzinhaber und Reverse-DNS im Sampler. Die ersten drei haben Abschalter; Reverse-DNS nicht, weil der Sampler sonst die meisten Verbindungen nicht benennen könnte. Fragen mit einem gehosteten Modell senden weit mehr und bleiben aus, bis du sie einrichtest.
Updates
Versionen sind mit einer Developer ID signiert und notarisiert. Die Updatefunktion verlangt eine veröffentlichte Prüfsumme und verweigert
einen Download ohne sie, und prüft dann vor der Installation, dass die heruntergeladene App vom selben Team signiert ist wie die laufende
Kopie. Ein Update, das jemand anderes signiert hat, wird verweigert, auch wenn es sonst gültig ist.
+
Offizielle Regelpakete verwenden eine separate zweckgebundene Signatur und eine einzige festgelegte HTTPS-Quelle. Die App verwaltet unabhängig davon die höchste Sequenznummer, verifiziert beim Start die exakten zwischengespeicherten Bytes erneut, weist Rollbacks und widersprüchliche Veröffentlichungen zurück und behandelt eine Herausgebersignatur niemals als Erlaubnis, geänderte Inhalte durchzusetzen. Neue oder geänderte Pakete erfordern eine ausdrückliche Prüfung des exakten Inhalts und Sichern; veraltete akzeptierte Inhalte werden als letzter bekannter funktionsfähiger Stand gekennzeichnet, während ein Vertrauensentzug oder beschädigte Bytes sicherheitshalber zur Sperrung führen.
Eine Schwachstelle melden
Nutze GitHubs private Schwachstellenmeldung oder schreib an
diff --git a/site/pages/docs.html b/site/pages/docs.html
index 9d9dc93d..7ff264aa 100644
--- a/site/pages/docs.html
+++ b/site/pages/docs.html
@@ -390,12 +390,16 @@
Rules
of them says the rule is safe. The screen also names the oldest traffic it could see: a rule tested against two hours of
history has been tested against two hours of history.
-
Managed ad and tracker packs
+
Managed ad and tracker packs
Rules › Packs contains three official lists built into Flowlight: Advertising covers known
advertising delivery and auction hosts; Cross-site tracking covers common analytics, session replay and
cross-site measurement hosts; and Privacy and telemetry covers crash reporting, install attribution, and
- audited Apple advertising and analytics hosts. The packs are visible from the start and all are off by default.
- Nothing changes on an update until you choose a pack and enable it.
+ audited Apple advertising and analytics hosts. The packs are visible from the start and all are off by default.
+
Official-only, signed updates. Flowlight checks one fixed HTTPS source for newer publisher-signed catalogs.
+ Automatic checks are on by default and can be switched off here. A signature authenticates content but does not enable it:
+ a new pack stays off, and changed destinations remove an enabled pack from enforcement until you review the exact new version
+ and press Save. If content changes while its editor is open, the stale Save is refused. An accepted catalog may remain available
+ as visibly stale last-known-good during an outage; rollback, equivocation, revoked trust, or corrupt cached bytes fail closed.
Each pack has its own scope. Apply it globally to every app, or only to selected applications. Blocking needs
the Network Extension: the nettop sampler can record traffic, but it cannot sit in a connection's path and refuse it. Essential
system traffic remains allowed — including the services that carry Apple accounts, push notifications, software updates,
@@ -656,9 +660,9 @@
Questions & answers
It shows which extensions are installed, whether macOS actually started Flowlight's filter, and how recently anything was recorded.
Paste it into an issue.
Does Flowlight send anything about me anywhere?
-
No account, no telemetry, no analytics, and your traffic database is never uploaded. Three requests do leave the Mac, all listed on the
- privacy page: the daily update check and the optional network-owner lookup, both with off switches, and
- reverse DNS while Flowlight is on the sampler, which has none — without it the sampler could not name most connections.
+
No account, no telemetry, no analytics, and your traffic database is never uploaded. Four requests can leave the Mac, all listed on the
+ privacy page: release and signed rule-pack checks plus the optional network-owner lookup, all with off
+ switches, and reverse DNS while Flowlight is on the sampler, which has none — without it the sampler could not name most connections.
Will it slow my Mac down or drain the battery?
With the window closed Flowlight does almost nothing: it keeps the menu bar rates and writes one row a second. The Network Extension
costs less than the fallback sampler, which starts a short-lived nettop process every second.
segura. La pantalla nombra también el tráfico más antiguo que pudo ver: una regla probada con dos horas de historial se ha
probado con dos horas de historial.
+
Paquetes gestionados de publicidad y rastreadores
+
Reglas › Paquetes contiene tres listas oficiales de nombres de host: Publicidad, Seguimiento entre sitios y Privacidad y telemetría. Son visibles desde el principio y todas están desactivadas de forma predeterminada. El bloqueo requiere la Extensión de red; el muestreador nettop solo puede observar.
+
Solo actualizaciones oficiales y firmadas. Flowlight consulta una única fuente HTTPS fija para buscar catálogos más recientes firmados por el editor. Las comprobaciones automáticas están activadas de forma predeterminada y pueden desactivarse aquí. Una firma autentica el contenido, pero no lo activa: un paquete nuevo permanece desactivado y, si cambian los destinos, un paquete activado deja de aplicarse hasta que revises la nueva versión exacta y pulses Guardar. Durante una interrupción, el contenido aceptado puede conservarse como la última versión válida conocida, marcada visiblemente como obsoleta. Estos son bloqueos de red a nivel de nombre de host: no pueden eliminar huecos del DOM o del diseño, superposiciones del navegador, ventanas emergentes generadas por scripts ni ventanas de aplicaciones nativas.
+
Listas de permitidos
Selecciona un agente en Agentes de IA y usa Lista de permitidos en su detalle para indicar adónde puede conectar. Introduce dominios
(subdominios incluidos), direcciones IP o rangos CIDR, o elige un preajuste: GitHub, npm, PyPI, Homebrew, Docker Hub, Rust crates, Go modules o
@@ -616,9 +620,7 @@
Preguntas y respuestas
Muestra qué extensiones están instaladas, si macOS arrancó de verdad el filtro de Flowlight y cuándo se registró algo por última vez.
Pégalo en una incidencia.
¿Flowlight envía algo sobre mí a alguna parte?
-
Sin cuenta, sin telemetría, sin analítica, y tu base de datos de tráfico nunca se sube. Tres peticiones sí salen del Mac, todas listadas en la
- página de privacidad: la comprobación diaria de actualizaciones y la consulta opcional del propietario de la red, las dos con
- interruptor para desactivarlas, y el DNS inverso mientras Flowlight está en el muestreador, que no lo tiene — sin él el muestreador no podría nombrar la mayoría de las conexiones.
+
Sin cuenta, sin telemetría, sin analítica, y tu base de datos de tráfico nunca se sube. La página de privacidad enumera cada petición: las comprobaciones de versión y de paquetes de reglas firmados, además de la consulta opcional del propietario de red, tienen interruptores; el DNS inverso solo funciona con el muestreador y no tiene uno porque sin él no podría nombrar la mayoría de las conexiones.
¿Ralentizará mi Mac o gastará la batería?
Con la ventana cerrada, Flowlight casi no hace nada: mantiene las tasas de la barra de menús y escribe una fila por segundo. La extensión de red
cuesta menos que el muestreador de reserva, que arranca un proceso nettop efímero cada segundo.
Cursor ⇥ raw.githubusercontent.com · rechazada 4 veces · de lunes a viernes 18:00–09:00
+
⛔ Previa activación
Paquetes gestionados de publicidad y rastreadores
Hay tres paquetes oficiales integrados, visibles y desactivados de forma predeterminada. Flowlight consulta un único feed HTTPS fijo para buscar actualizaciones firmadas; el contenido nuevo o modificado no se aplica hasta que revises esa versión exacta y pulses Guardar. Durante una interrupción, el contenido verificado y aceptado puede conservarse como la última versión válida conocida, marcada visiblemente como obsoleta.
Bloquean por nombre de host, por lo que no pueden eliminar anuncios propios del sitio, huecos del DOM o del diseño, superposiciones del navegador, ventanas emergentes generadas por scripts ni ventanas de aplicaciones nativas.
⛔ Rechaza
Salvaguardas sobre las herramientas de un agente
@@ -366,7 +367,7 @@
Un monitor que no tienes que vigilar
Se queda en tu Mac
Un único archivo SQLite local. Sin cuenta, sin sincronización en la nube, sin telemetría.
Metadatos por defecto
Nombres de host, puertos y bytes. Descifrar HTTPS es un modo aparte que tienes que activar, y también se queda en tu Mac.
Filtrado en el núcleo
La captura solo deja pasar las respuestas DNS y los ClientHello de TLS. Todo lo demás se descarta antes de que Flowlight lo vea.
-
Consultas que tú controlas
Para saber de quién es una dirección solo se envían a Team Cymru las IP públicas sin nombre de host, y se desactiva con un clic. La comprobación diaria de actualizaciones solo pregunta a GitHub por la última versión, y también puede desactivarse.
+
Consultas que tú controlas
Para saber de quién es una dirección solo se envían a Team Cymru las IP públicas sin nombre de host. Las comprobaciones de versiones y de paquetes de reglas firmados envían solicitudes HTTPS estándar sin tráfico ni configuración de paquetes. Cada una tiene su propio interruptor.
Una petición HTTPS normal de la última versión; GitHub ve tu dirección IP y el agente de usuario Flowlight/<version>
Al arrancar y a diario, desde la 0.1.2. Interruptor para desactivarla en Ajustes › General
Descarga de la actualización
github.com
La imagen de disco y su archivo de suma de verificación
Solo cuando pulsas Descargar y abrir
+
Actualización oficial del paquete de reglas
flowlight.xinbetween.com
Una solicitud HTTPS estándar al feed oficial fijo. El servidor ve tu dirección IP, el agente de usuario Flowlight/<version> y los validadores condicionales de caché; no recibe tráfico, ajustes de paquetes ni ningún identificador del dispositivo
Después del inicio, cuando corresponda, y luego como máximo una vez al día mientras todo funcione correctamente. Activada de forma predeterminada; interruptor para desactivarla y opción manual Buscar ahora en Reglas › Paquetes
DNS inverso
El servidor DNS que tengas configurado
Una consulta del nombre registrado de una dirección IP, cuando no se ha visto ningún nombre de host
Según haga falta, en el modo de captura del muestreador, que es el predeterminado hasta que la extensión de red esté en marcha. Sin interruptor para desactivarla: sin ella el muestreador no puede nombrar la mayoría de las conexiones. La extensión de red las nombra a partir del propio tráfico y no hace esta consulta
Consulta del propietario de la red
Team Cymru, por DNS
Direcciones IP públicas sin nombre de host conocido. Las direcciones privadas y reservadas no salen nunca de tu Mac
Activada por defecto. Interruptor para desactivarla en Captura › Identificar a los propietarios de la red
Preguntar con un modelo alojado
El proveedor que elijas: Anthropic, OpenAI, Google Gemini o un endpoint compatible
Tu pregunta y los resultados que devuelvan las consultas de solo lectura de Flowlight que elija el modelo. La base de datos de tráfico en sí no se sube
Solo cuando eliges un proveedor alojado y envías una pregunta. La pantalla Preguntar muestra las peticiones transmitidas
cuerpos de las peticiones y las respuestas durante tres días: lo más delicado que llega a conservar. Las cabeceras con credenciales se
censuran por nombre y por palabra, lo cual es una heurística y no una garantía; una cabecera que lleve un secreto con un nombre que nada
reconoce puede quedar registrada.
-
La base de datos de tráfico nunca se sube. Tres cosas más pequeñas sí salen del Mac sin que nadie las pida, las tres nombradas en la
- página de privacidad: la comprobación de actualizaciones, que pide a GitHub la última versión al arrancar y a
- diario; la consulta del propietario de la red, que pregunta a Team Cymru por direcciones públicas que no tienen nombre de host; y, mientras
- Flowlight lee contadores en vez de ejecutar su extensión, una consulta de DNS inverso de las direcciones para las que no vio ningún nombre.
- Las dos primeras se pueden desactivar. La tercera no, porque en el muestreador es casi la única manera en la que una conexión llega a tener
- nombre: va al servidor DNS que el Mac ya usa, y se detiene en cuanto la extensión toma el relevo y nombra las conexiones a partir del propio
- tráfico. Preguntar con un modelo alojado envía mucho más que cualquiera de ellas, y es lo único que está desactivado hasta que lo
- configuras.
+
La base de datos de tráfico nunca se sube. La página de privacidad enumera cada petición saliente: comprobaciones de versión y de paquetes de reglas firmados, la consulta opcional del propietario de red y DNS inverso en el muestreador. Las tres primeras tienen interruptores; el DNS inverso no, porque sin él el muestreador no podría nombrar la mayoría de las conexiones. Preguntar con un modelo alojado envía mucho más y permanece desactivado hasta que lo configuras.
Actualizaciones
Las versiones se firman con un Developer ID y se notarizan. El actualizador exige una suma de comprobación publicada y rechaza una descarga
que no la tenga; después comprueba que la app descargada esté firmada por el mismo equipo que la copia en ejecución antes de instalarla. Una
actualización firmada por otra persona se rechaza aunque por lo demás sea válida.
+
Los paquetes de reglas oficiales usan una firma independiente limitada a este fin y una única fuente HTTPS fija. La aplicación mantiene por separado una marca de la secuencia más alta, vuelve a verificar al iniciarse los bytes exactos almacenados en caché, rechaza las regresiones y las publicaciones contradictorias, y nunca considera la firma del editor como permiso para aplicar contenido modificado. Los paquetes nuevos o modificados requieren la revisión explícita del contenido exacto y Guardar; el contenido obsoleto ya aceptado se etiqueta como la última versión válida conocida, mientras que la revocación de la confianza o los bytes dañados provocan un rechazo seguro.
Informar de una vulnerabilidad
Usa el informe privado de vulnerabilidades de GitHub, o escribe a
diff --git a/site/pages/fr/docs.html b/site/pages/fr/docs.html
index 0fbb2885..12fc19cd 100644
--- a/site/pages/fr/docs.html
+++ b/site/pages/fr/docs.html
@@ -383,6 +383,10 @@
Règles
règle est sans danger. L’écran nomme aussi le trafic le plus ancien qu’il a pu voir : une règle testée sur deux heures
d’historique a été testée sur deux heures d’historique.
+
Packs gérés de publicité et de traqueurs
+
Règles › Packs contient trois listes officielles de noms d’hôte : Publicité, Suivi intersites et Confidentialité et télémétrie. Elles sont visibles dès le départ et toutes désactivées par défaut. Le blocage nécessite l’Extension réseau ; l’échantillonneur nettop ne peut qu’observer.
+
Uniquement des mises à jour officielles et signées. Flowlight consulte une source HTTPS fixe unique pour rechercher des catalogues plus récents signés par l’éditeur. Les vérifications automatiques sont activées par défaut et peuvent être désactivées ici. Une signature authentifie le contenu, mais ne l’active pas : un nouveau pack reste désactivé et, si les destinations changent, l’application d’un pack activé est suspendue jusqu’à ce que vous examiniez la nouvelle version exacte et appuyiez sur Enregistrer. Pendant une panne, le contenu accepté peut rester disponible comme dernière version valide connue, visiblement signalée comme obsolète. Il s’agit de blocages réseau au niveau des noms d’hôte : ils ne peuvent pas supprimer les vides dans le DOM ou la mise en page, les superpositions du navigateur, les fenêtres contextuelles générées par des scripts ni les fenêtres d’applications natives.
+
Listes d’autorisation
Sélectionnez un agent dans Agents IA et utilisez Liste d’autorisation dans son détail pour dire où il peut se connecter. Saisissez des domaines
(sous-domaines inclus), des adresses IP ou des plages CIDR, ou choisissez un préréglage : GitHub, npm, PyPI, Homebrew, Docker Hub, crates Rust, modules Go ou
@@ -622,9 +626,7 @@
Questions & réponses
Cela montre quelles extensions sont installées, si macOS a réellement démarré le filtre de Flowlight, et à quand remonte le dernier enregistrement.
Collez le résultat dans un ticket.
Flowlight envoie-t-il quoi que ce soit à mon sujet, où que ce soit ?
-
Ni compte, ni télémétrie, ni analytique, et votre base de données de trafic n’est jamais téléversée. Trois requêtes quittent bien le Mac, toutes listées sur la
- page Confidentialité : la recherche quotidienne de mise à jour et la consultation facultative du propriétaire de réseau, toutes
- deux avec un interrupteur d’arrêt, et le DNS inverse tant que Flowlight tourne sur l’échantillonneur, qui n’en a pas — sans lui, l’échantillonneur ne pourrait pas nommer la plupart des connexions.
+
Ni compte, ni télémétrie, ni analytique, et votre base de données de trafic n’est jamais téléversée. La page Confidentialité répertorie chaque requête : les vérifications de version et de packs de règles signés, ainsi que la consultation facultative du propriétaire du réseau, ont des interrupteurs ; le DNS inverse ne fonctionne qu’avec l’échantillonneur et n’en a pas, car sans lui la plupart des connexions resteraient sans nom.
Va-t-il ralentir mon Mac ou vider la batterie ?
Fenêtre fermée, Flowlight ne fait presque rien : il tient à jour les débits de la barre des menus et écrit une ligne par seconde. L’extension réseau
coûte moins que l’échantillonneur de secours, qui lance un processus nettop éphémère chaque seconde.
une barrière contre ceux auxquels vous n’en faites pas — voyez le modèle de menaces.
Cursor ⇥ raw.githubusercontent.com · refusé 4 fois · en semaine 18:00–09:00
+
⛔ Sur activation
Packs gérés de publicité et de traqueurs
Trois packs officiels sont intégrés, visibles et désactivés par défaut. Flowlight consulte un flux HTTPS fixe unique pour rechercher les mises à jour signées ; tout contenu nouveau ou modifié reste inappliqué jusqu’à ce que vous examiniez cette version exacte et appuyiez sur Enregistrer. Pendant une panne, le contenu vérifié et accepté peut être conservé comme dernière version valide connue, visiblement signalée comme obsolète.
Ils bloquent par nom d’hôte et ne peuvent donc pas supprimer les publicités internes au site, les vides dans le DOM ou la mise en page, les superpositions du navigateur, les fenêtres contextuelles générées par des scripts ni les fenêtres d’applications natives.
⛔ Refuse
Des garde-fous sur les outils d’un agent
@@ -366,7 +367,7 @@
Un moniteur que vous n’avez pas à surveiller
Reste sur votre Mac
Un seul fichier SQLite local. Pas de compte, pas de synchronisation cloud, pas de télémétrie.
Des métadonnées par défaut
Noms d’hôte, ports et nombre d’octets. Déchiffrer le HTTPS est un mode distinct que vous devez activer, et qui reste sur votre Mac.
Filtré dans le noyau
La capture ne laisse passer que les réponses DNS et les ClientHello TLS. Tout le reste est écarté avant que Flowlight le voie.
-
Des recherches que vous maîtrisez
Nommer le propriétaire d’une adresse n’envoie à Team Cymru que des IP publiques sans nom d’hôte, et se désactive en un clic. La recherche quotidienne de mise à jour ne demande à GitHub que la dernière version, et peut aussi être désactivée.
+
Des recherches que vous maîtrisez
Nommer le propriétaire d’une adresse n’envoie à Team Cymru que les IP publiques sans nom d’hôte. Les vérifications de version et de packs de règles signés envoient des requêtes HTTPS standard sans trafic ni réglages de packs. Chacune dispose de son propre interrupteur.
Une requête HTTPS standard pour la dernière version ; GitHub voit votre adresse IP et l’agent utilisateur Flowlight/<version>
Au lancement et une fois par jour, depuis 0.1.2. Interrupteur d’arrêt dans Réglages › Général
Téléchargement de mise à jour
github.com
L’image disque et son fichier de somme de contrôle
Seulement quand vous cliquez sur Télécharger & ouvrir
+
Mise à jour du pack de règles officiel
flowlight.xinbetween.com
Une requête HTTPS standard vers le flux officiel fixe. Le serveur voit votre adresse IP, l’agent utilisateur Flowlight/<version> et les validateurs conditionnels du cache ; il ne reçoit ni trafic, ni réglages des packs, ni identifiant d’appareil
Après le démarrage, lorsqu’une vérification est due, puis au maximum une fois par jour tant que le service fonctionne normalement. Activée par défaut ; interrupteur de désactivation et option manuelle Rechercher maintenant dans Règles › Packs
DNS inverse
Le résolveur DNS que vous avez configuré
Une recherche du nom enregistré d’une adresse IP, quand aucun nom d’hôte n’a été observé
Au besoin, dans le mode de capture par échantillonnage, qui est celui par défaut jusqu’à ce que l’extension réseau tourne. Pas d’interrupteur d’arrêt : sans elle, l’échantillonneur ne peut pas nommer la plupart des connexions. L’extension réseau les nomme à partir du trafic lui-même et ne fait pas cette recherche
Recherche du propriétaire de réseau
Team Cymru, par DNS
Des adresses IP publiques sans nom d’hôte connu. Les adresses privées et réservées ne quittent jamais votre Mac
Activé par défaut. Interrupteur d’arrêt dans Capture › Identifier les propriétaires de réseau
Demander avec un modèle hébergé
Le fournisseur que vous choisissez : Anthropic, OpenAI, Google Gemini ou un point de terminaison compatible
Votre question et les résultats renvoyés par les requêtes Flowlight en lecture seule choisies par le modèle. La base de données de trafic elle-même n’est pas envoyée
Seulement quand vous choisissez un fournisseur hébergé et soumettez une question. L’écran Demander montre les requêtes transmises
conserve aussi les corps des requêtes et des réponses pendant trois jours — la chose la plus sensible qu’il garde jamais. Les en-têtes
d’identification sont masqués par nom et par mot, ce qui est une heuristique et non une garantie ; un en-tête qui porte un secret sous un
nom que rien ne reconnaît peut être enregistré.
-
La base de données de trafic n’est jamais téléversée. Trois choses plus petites quittent bien le Mac sans qu’on les demande, toutes trois
- nommées sur la page Confidentialité : la recherche de mise à jour, qui demande à GitHub la dernière version
- au lancement et une fois par jour ; la consultation du propriétaire du réseau, qui interroge Team Cymru sur les adresses publiques sans
- nom d’hôte ; et, tant que Flowlight lit des compteurs au lieu de faire tourner son extension, une recherche DNS inverse des adresses dont
- il n’a vu aucun nom. Les deux premières ont un interrupteur d’arrêt. La troisième non, parce que sur l’échantillonneur c’est l’essentiel de la
- façon dont une connexion obtient un nom : elle va au résolveur que le Mac utilise déjà, et elle s’arrête dès que l’extension prend le
- relais et nomme les connexions à partir du trafic lui-même. Demander avec un modèle hébergé envoie bien plus que n’importe laquelle d’entre
- elles, et c’est la seule chose qui reste désactivée jusqu’à ce que vous la configuriez.
+
La base de données de trafic n’est jamais téléversée. La page Confidentialité répertorie chaque requête sortante : vérifications de version et de packs de règles signés, consultation facultative du propriétaire du réseau et DNS inverse avec l’échantillonneur. Les trois premières ont des interrupteurs ; le DNS inverse non, car sans lui l’échantillonneur ne pourrait pas nommer la plupart des connexions. Un modèle hébergé envoie bien plus et reste désactivé jusqu’à configuration.
Mises à jour
Les versions sont signées avec un Developer ID et notarisées. Le programme de mise à jour exige une somme de contrôle publiée et refuse un
téléchargement qui n’en a pas, puis vérifie que l’app téléchargée est signée par la même équipe que la copie en cours d’exécution avant de
l’installer. Une mise à jour signée par quelqu’un d’autre est refusée, même si elle est valide par ailleurs.
+
Les packs de règles officiels utilisent une signature distincte limitée à cet usage et une source HTTPS fixe unique. L’application conserve séparément un repère de la séquence la plus élevée, revérifie au démarrage les octets exacts mis en cache, rejette les retours en arrière et les publications contradictoires, et ne considère jamais la signature d’un éditeur comme une autorisation d’appliquer un contenu modifié. Les packs nouveaux ou modifiés exigent l’examen explicite du contenu exact et Enregistrer ; le contenu obsolète déjà accepté est étiqueté comme dernière version valide connue, tandis qu’une révocation de confiance ou des octets corrompus entraînent un refus sécurisé.
Signaler une vulnérabilité
Utilisez le signalement privé de vulnérabilités de GitHub, ou écrivez à
diff --git a/site/pages/index.html b/site/pages/index.html
index 1263796d..5509d049 100644
--- a/site/pages/index.html
+++ b/site/pages/index.html
@@ -180,7 +180,9 @@
Monitor how AI agents use the network
⛔ Opt-in
Managed ad and tracker packs
Three official packs — Advertising, Cross-site tracking, and Privacy and telemetry — are built in, visible and off by
- default. With the Network Extension, apply one to every app or only selected apps; preview it against retained traffic,
+ default. Flowlight checks one fixed HTTPS feed for signed updates; new or changed content remains out of enforcement until
+ you review that exact version and press Save. During an outage, verified accepted content is visibly stale last-known-good.
+
With the Network Extension, apply one to every app or only selected apps; preview it against retained traffic,
see grouped, explainable activity, and add pack-specific exceptions for an app. Ordinary allow rules remain the universal
escape hatch, global Pause Blocking applies, and essential system traffic stays allowed.
They block by hostname, so they cannot remove first-party ads, DOM or layout gaps, browser overlays, scripted popups, or
@@ -387,7 +389,7 @@
A monitor you don't have to monitor
Stays on your Mac
One local SQLite file. No account, no cloud sync, no telemetry.
Metadata by default
Hostnames, ports and byte counts. Decrypting HTTPS is a separate mode you have to turn on, and it stays on your Mac.
Filtered in the kernel
Capture passes only DNS answers and TLS ClientHellos. Everything else is dropped before Flowlight sees it.
-
Lookups you control
Naming who owns an address sends only public IPs with no hostname to Team Cymru, and turns off in one click. The daily update check only asks GitHub for the latest version, and can be turned off too.
+
Lookups you control
Naming who owns an address sends only public IPs with no hostname to Team Cymru. Release and signed rule-pack checks send standard HTTPS requests with no traffic or pack settings. Each has an off switch.
sicura. La schermata indica anche il traffico più vecchio che ha potuto vedere: una regola testata su due ore di storico è stata
testata su due ore di storico.
+
Pacchetti gestiti per pubblicità e tracker
+
Regole › Pacchetti contiene tre elenchi ufficiali di nomi host: Pubblicità, Tracciamento tra siti e Privacy e telemetria. Sono visibili fin dall’inizio e tutti disattivati per impostazione predefinita. Il blocco richiede l’Estensione di rete; il campionatore nettop può solo osservare.
+
Solo aggiornamenti ufficiali e firmati. Flowlight controlla un’unica fonte HTTPS fissa per trovare cataloghi più recenti firmati dall’editore. I controlli automatici sono attivi per impostazione predefinita e possono essere disattivati qui. Una firma autentica il contenuto, ma non lo attiva: un nuovo pacchetto resta disattivato e, se cambiano le destinazioni, l’applicazione di un pacchetto attivo viene sospesa finché non esamini l’esatta nuova versione e premi Salva. Durante un’interruzione, i contenuti accettati possono restare disponibili come ultima versione valida nota, visibilmente indicata come obsoleta. Si tratta di blocchi di rete a livello di nome host: non possono eliminare spazi vuoti nel DOM o nel layout, sovrapposizioni del browser, popup generati da script o finestre di app native.
+
Elenchi consentiti
Seleziona un agente in Agenti IA e usa Elenco consentiti nel suo dettaglio per dire dove può connettersi. Inserisci domini
(sottodomini compresi), indirizzi IP o intervalli CIDR, oppure scegli una preimpostazione: GitHub, npm, PyPI, Homebrew, Docker Hub, Rust crates, Go modules o
@@ -620,9 +624,7 @@
Domande e risposte
Mostra quali estensioni sono installate, se macOS ha davvero avviato il filtro di Flowlight e quanto di recente è stato registrato qualcosa.
Incollalo in una segnalazione.
Flowlight invia da qualche parte informazioni su di me?
-
Nessun account, nessuna telemetria, nessuna analisi, e il tuo database del traffico non viene mai caricato. Tre richieste lasciano il Mac, tutte elencate nella
- pagina sulla privacy: il controllo giornaliero degli aggiornamenti e la ricerca facoltativa sul proprietario della rete, entrambe con
- un interruttore per disattivarle, e il DNS inverso finché Flowlight è sul campionatore, che non ne ha uno — senza di esso il campionatore non potrebbe dare un nome alla maggior parte delle connessioni.
+
Nessun account, nessuna telemetria, nessuna analisi, e il tuo database del traffico non viene mai caricato. La pagina sulla privacy elenca ogni richiesta: i controlli della versione e dei pacchetti di regole firmati, insieme alla ricerca facoltativa del proprietario della rete, hanno interruttori; il DNS inverso funziona solo con il campionatore e non ne ha uno, perché senza di esso non potrebbe nominare la maggior parte delle connessioni.
Rallenta il Mac o consuma la batteria?
Con la finestra chiusa Flowlight fa quasi nulla: mantiene le velocità nella barra dei menu e scrive una riga al secondo. L'estensione di rete
costa meno del campionatore di riserva, che avvia un breve processo nettop ogni secondo.
Cursor ⇥ raw.githubusercontent.com · rifiutata 4 volte · nei giorni feriali 18:00–09:00
+
⛔ Da attivare
Pacchetti gestiti per pubblicità e tracker
Tre pacchetti ufficiali sono integrati, visibili e disattivati per impostazione predefinita. Flowlight controlla un unico feed HTTPS fisso per gli aggiornamenti firmati; i contenuti nuovi o modificati restano esclusi dall’applicazione finché non esamini quella versione esatta e premi Salva. Durante un’interruzione, i contenuti verificati e accettati possono restare disponibili come ultima versione valida nota, visibilmente indicata come obsoleta.
Bloccano in base al nome host, quindi non possono rimuovere pubblicità proprietarie, spazi vuoti nel DOM o nel layout, sovrapposizioni del browser, popup generati da script o finestre native delle app.
⛔ Rifiuta
Protezioni sugli strumenti di un agente
@@ -366,7 +367,7 @@
Un monitoraggio che non devi monitorare
Resta sul tuo Mac
Un solo file SQLite locale. Nessun account, nessuna sincronizzazione nel cloud, nessuna telemetria.
Metadati, per impostazione predefinita
Nomi host, porte e byte conteggiati. Decifrare HTTPS è una modalità separata che devi attivare tu, e resta sul tuo Mac.
Filtrato nel kernel
L'acquisizione lascia passare solo le risposte DNS e i ClientHello TLS. Tutto il resto viene scartato prima che Flowlight lo veda.
-
Ricerche che controlli tu
Per dare un nome al proprietario di un indirizzo vengono inviati a Team Cymru solo gli IP pubblici senza nome host, e si disattiva con un clic. Il controllo giornaliero degli aggiornamenti chiede a GitHub solo l'ultima versione, e anche quello si può disattivare.
+
Ricerche che controlli tu
Per dare un nome al proprietario di un indirizzo vengono inviati a Team Cymru solo gli IP pubblici senza nome host. I controlli della versione e dei pacchetti di regole firmati inviano richieste HTTPS standard senza traffico né impostazioni dei pacchetti. Ciascuno ha il proprio interruttore.
Una normale richiesta HTTPS per l'ultima versione; GitHub vede il tuo indirizzo IP e lo user agent Flowlight/<version>
All'avvio e una volta al giorno, dalla 0.1.2. Interruttore per disattivarla in Impostazioni › Generali
Download dell'aggiornamento
github.com
L'immagine disco e il suo file di checksum
Solo quando fai clic su Scarica e apri
+
Aggiornamento ufficiale del pacchetto di regole
flowlight.xinbetween.com
Una richiesta HTTPS standard al feed ufficiale fisso. Il server vede il tuo indirizzo IP, lo user agent Flowlight/<version> e i validatori condizionali della cache; non riceve traffico, impostazioni dei pacchetti o identificatori del dispositivo
Dopo l’avvio, quando previsto, e successivamente non più di una volta al giorno durante il normale funzionamento. Attivo per impostazione predefinita; interruttore per disattivarlo e opzione manuale Controlla ora in Regole › Pacchetti
DNS inverso
Il resolver DNS che hai configurato
Una ricerca del nome registrato di un indirizzo IP, quando non è stato rilevato alcun nome host
Quando serve, nella modalità di acquisizione a campionamento, che è quella predefinita finché l'estensione di rete non è in funzione. Nessun interruttore per disattivarla: senza di essa il campionatore non può dare un nome alla maggior parte delle connessioni. L'estensione di rete le nomina dal traffico stesso e non fa questa ricerca
Ricerca del proprietario della rete
Team Cymru, via DNS
Indirizzi IP pubblici senza nome host noto. Gli indirizzi privati e riservati non lasciano mai il tuo Mac
Attiva per impostazione predefinita. Interruttore per disattivarla in Acquisizione › Identifica i proprietari della rete
Chiedi con un modello ospitato
Il provider che selezioni: Anthropic, OpenAI, Google Gemini o un endpoint compatibile
La tua domanda e i risultati restituiti dalle query di sola lettura di Flowlight selezionate dal modello. Il database del traffico non viene caricato
Solo quando selezioni un provider ospitato e invii una domanda. La schermata Chiedi mostra le richieste trasmesse
richieste e delle risposte per tre giorni — la cosa più delicata che arrivi mai a tenere. Le intestazioni con credenziali vengono oscurate per
nome e per parola, che è un'euristica e non una garanzia; un'intestazione che porta un segreto sotto un nome che nulla riconosce può essere
registrata.
-
Il database del traffico non viene mai caricato. Tre cose più piccole lasciano il Mac senza che nessuno le chieda, tutte e tre nominate nella
- pagina sulla privacy: il controllo degli aggiornamenti, che chiede a GitHub l'ultima versione all'avvio e una
- volta al giorno; la ricerca del proprietario della rete, che chiede a Team Cymru degli indirizzi pubblici senza nome host; e, finché Flowlight
- legge i contatori invece di far girare la sua estensione, una ricerca DNS inversa degli indirizzi per cui non ha visto alcun nome. Le prime due
- hanno un interruttore per disattivarle. La terza no, perché sul campionatore è in gran parte il modo in cui una connessione arriva ad avere un
- nome: va al resolver che il Mac già usa, e si ferma appena l'estensione prende il suo posto e nomina le connessioni dal traffico stesso. Chiedi
- con un modello ospitato invia molto più di ognuna di esse, ed è la sola cosa che resta disattivata finché non la configuri.
+
Il database del traffico non viene mai caricato. La pagina sulla privacy elenca ogni richiesta in uscita: controlli della versione e dei pacchetti di regole firmati, ricerca facoltativa del proprietario della rete e DNS inverso con il campionatore. Le prime tre hanno interruttori; il DNS inverso no, perché senza di esso il campionatore non potrebbe nominare la maggior parte delle connessioni. Un modello ospitato invia molto di più e resta disattivato finché non lo configuri.
Aggiornamenti
Le versioni sono firmate con un Developer ID e notarizzate. L'aggiornamento richiede una checksum pubblicata e rifiuta un download che non ne
ha una, poi verifica che l'app scaricata sia firmata dallo stesso team della copia in esecuzione prima di installarla. Un aggiornamento firmato
da qualcun altro viene rifiutato anche se per il resto è valido.
+
I pacchetti di regole ufficiali usano una firma distinta e limitata a questo scopo e un’unica fonte HTTPS fissa. L’app conserva separatamente un marcatore della sequenza più alta, verifica di nuovo all’avvio gli esatti byte memorizzati nella cache, rifiuta rollback e pubblicazioni discordanti e non considera mai la firma dell’editore un’autorizzazione ad applicare contenuti modificati. I pacchetti nuovi o modificati richiedono l’esame esplicito del contenuto esatto e Salva; i contenuti obsoleti già accettati sono indicati come ultima versione valida nota, mentre la revoca dell’attendibilità o byte danneggiati fanno rifiutare il contenuto per sicurezza.
Segnalare una vulnerabilità
Usa la segnalazione privata delle vulnerabilità di GitHub, oppure scrivi a
diff --git a/site/pages/ja/docs.html b/site/pages/ja/docs.html
index aa7c3d80..d88f0bb1 100644
--- a/site/pages/ja/docs.html
+++ b/site/pages/ja/docs.html
@@ -352,6 +352,10 @@
アカウントもテレメトリも分析もなく、トラフィックのデータベースがアップロードされることもありません。プライバシーのページにはすべてのリクエストを記載しています。リリースと署名付きルールパックの確認、任意のネットワーク所有者照会にはオフのスイッチがあります。逆引き DNS はサンプラー使用時だけ動作し、これがないと多くの接続に名前を付けられないためスイッチはありません。
トラフィックのデータベースがアップロードされることはありません。それでも、頼まなくても Mac から出ていくものが 3 つあり、
- どれもプライバシーのページに挙げてあります。起動時と 1 日 1 回、最新のリリースを GitHub に
- 尋ねるアップデートの確認。ホスト名のない公開アドレスについて Team Cymru に尋ねるネットワーク所有者の照会。そして、
- Flowlight が機能拡張ではなくカウンタを読んでいるあいだ、名前が見つからなかったアドレスの逆引き DNS 照会です。
- はじめの 2 つにはオフのスイッチがあります。3 つめにはありません。サンプラーでは、接続に名前が付く道のほとんどがそれだからです。
- 照会はこの Mac がすでに使っているリゾルバへ行き、機能拡張が引き継いでトラフィック自身から名前を付けるようになれば止まります。
- ホスト型モデルでの質問は、そのどれよりもはるかに多くを送りますが、設定するまではオフのままである唯一のものです。
+
トラフィックのデータベースがアップロードされることはありません。プライバシーのページには、リリースと署名付きルールパックの確認、任意のネットワーク所有者照会、サンプラーの逆引き DNS というすべての送信を記載しています。最初の 3 つにはオフのスイッチがあります。逆引き DNS は、多くの接続に名前を付けるため必要なのでありません。ホスト型モデルはより多くを送信し、設定するまでオフです。
アップデート
リリースは Developer ID で署名され、公証されています。アップデート機能は公開されたチェックサムを要求し、それがないダウンロードを
拒否します。そのうえで、ダウンロードしたアプリが実行中のコピーと同じチームの署名であることを確かめてから、インストールします。
別の誰かが署名したアップデートは、ほかの点で有効であっても拒否されます。
트래픽이 없습니다”를 구분합니다. 같은 빈 목록에 뜻은 정반대이고, 규칙이 안전하다고 말해 주는 것은 둘 중 하나뿐입니다. 화면은 볼 수
있었던 가장 오래된 트래픽도 함께 밝힙니다. 두 시간짜리 이력으로 대조한 규칙은 두 시간짜리 이력으로 대조한 것입니다.
+
관리형 광고 및 추적기 팩
+
규칙 › 팩에는 광고, 사이트 간 추적, 개인정보 보호 및 원격 측정이라는 세 가지 공식 호스트 이름 목록이 있습니다. 처음부터 표시되며 모두 기본적으로 꺼져 있습니다. 차단하려면 네트워크 확장 프로그램이 필요하며, nettop 샘플러는 관찰만 할 수 있습니다.
+
공식 서명 업데이트만 허용됩니다. Flowlight는 게시자가 서명한 최신 카탈로그가 있는지 하나의 고정 HTTPS 소스를 확인합니다. 자동 확인은 기본적으로 켜져 있으며 여기에서 끌 수 있습니다. 서명은 콘텐츠를 인증하지만 활성화하지는 않습니다. 새 팩은 꺼진 상태로 유지되며, 대상이 변경되면 활성화된 팩은 정확한 새 버전을 검토하고 저장을 누를 때까지 적용되지 않습니다. 서비스 중단 중에도 승인된 콘텐츠가 눈에 띄게 오래된 것으로 표시된 마지막 정상 버전으로 유지될 수 있습니다. 이는 호스트 이름 수준의 네트워크 차단이므로 DOM이나 레이아웃의 빈 공간, 브라우저 오버레이, 스크립트로 생성된 팝업 또는 네이티브 앱 창은 제거할 수 없습니다.
+
허용 목록
AI 에이전트에서 에이전트를 고르고 상세의 허용 목록에서 어디에 연결할 수 있는지 정하세요.
도메인(서브도메인 포함)이나 IP 주소, CIDR 범위를 입력하거나 사전 설정을 고르면 됩니다. GitHub, npm, PyPI, Homebrew, Docker Hub,
@@ -611,10 +615,7 @@
질문과 답
어떤 확장이 설치되어 있는지, macOS가 Flowlight의 필터를 실제로 시작했는지, 그리고 가장 최근 기록이 언제인지 보여줍니다.
결과를 이슈에 붙여 주세요.
Flowlight가 내 정보를 어딘가로 보내나요?
-
계정도, 텔레메트리도, 분석도 없고, 트래픽 데이터베이스가 업로드되는 일도 없습니다. Mac을 떠나는 요청은 세 가지이고, 셋 다
- 개인정보 페이지에 나열되어 있습니다. 하루 한 번의 업데이트 확인과 선택 사항인 네트워크 소유자 조회,
- 이 둘은 끄는 스위치가 있습니다. 그리고 Flowlight가 샘플러로 돌고 있는 동안의 역방향 DNS, 이것은 스위치가 없습니다. 그것이 없으면
- 샘플러는 대부분의 연결에 이름을 붙일 수 없습니다.
+
계정도, 텔레메트리도, 분석도 없고 트래픽 데이터베이스도 업로드되지 않습니다. 개인정보 페이지에 모든 요청이 나열됩니다. 릴리스와 서명된 규칙 팩 확인, 선택 사항인 네트워크 소유자 조회에는 끄기 스위치가 있습니다. 역방향 DNS는 샘플러에서만 실행되며, 없으면 대부분의 연결에 이름을 붙일 수 없어 스위치가 없습니다.
Mac이 느려지거나 배터리가 빨리 닳지 않나요?
창을 닫아 두면 Flowlight는 거의 아무 일도 하지 않습니다. 메뉴 막대의 속도를 유지하고 1초에 한 행을 씁니다. 네트워크 확장은
1초마다 짧게 사는 nettop 프로세스를 실행하는 대체 샘플러보다 비용이 적습니다.
세 가지 공식 팩이 내장되어 있고 처음부터 표시되지만 모두 기본적으로 꺼져 있습니다. Flowlight는 서명된 업데이트가 있는지 하나의 고정 HTTPS 피드를 확인합니다. 새 콘텐츠나 변경된 콘텐츠는 정확한 해당 버전을 검토하고 저장을 누를 때까지 차단 적용에서 제외됩니다. 서비스 중단 중에는 검증되고 승인된 콘텐츠가 오래된 상태임을 명확히 표시한 마지막 정상 버전으로 유지될 수 있습니다.
호스트 이름을 기준으로 차단하므로 자사 광고, DOM이나 레이아웃의 빈 공간, 브라우저 오버레이, 스크립트로 생성된 팝업 또는 네이티브 앱 창은 제거할 수 없습니다.
⛔ 거부
에이전트의 도구에 두는 가드레일
@@ -366,7 +367,7 @@
감시하지 않아도 되는 감시 도구
Mac 안에 머무릅니다
로컬 SQLite 파일 하나. 계정도, 클라우드 동기화도, 텔레메트리도 없습니다.
기본은 메타데이터
호스트 이름과 포트, 바이트 수. HTTPS 복호화는 직접 켜야 하는 별도 모드이며, 그 결과도 Mac 안에 남습니다.
커널에서 걸러냅니다
캡처는 DNS 응답과 TLS ClientHello만 통과시킵니다. 나머지는 Flowlight가 보기 전에 버려집니다.
-
조회는 내가 정합니다
주소의 소유자를 알아낼 때는 호스트 이름이 없는 공용 IP만 Team Cymru로 보내며, 클릭 한 번으로 끌 수 있습니다. 하루 한 번 업데이트 확인은 GitHub에 최신 버전만 물어보고, 이것도 끌 수 있습니다.
+
조회는 내가 정합니다
주소의 소유자를 알아낼 때는 호스트 이름이 없는 공용 IP만 Team Cymru로 보냅니다. 릴리스와 서명된 규칙 팩 확인은 트래픽이나 팩 설정 없이 표준 HTTPS 요청을 보냅니다. 각각 별도로 끌 수 있습니다.
트래픽 메타데이터를 로컬 SQLite 데이터베이스에, 공개된 보관 기간만큼 갖고 있습니다. HTTPS 검사를 켜 두면 요청과 응답의 본문도
3일 동안 갖고 있는데, 이것이 Flowlight가 보관하는 것 중 가장 민감한 것입니다. 인증 정보 헤더는 이름과 낱말로 가려지지만, 그것은
추정 규칙이고 보장이 아닙니다. 아무것도 알아보지 못하는 이름 아래 비밀을 실어 보내는 헤더는 기록될 수 있습니다.
-
트래픽 데이터베이스는 업로드되지 않습니다. 그래도 묻지 않아도 Mac을 떠나는 것이 세 가지 있고, 셋 다
- 개인정보 페이지에 이름이 적혀 있습니다. 실행할 때와 하루에 한 번 GitHub에 최신 릴리스를 묻는
- 업데이트 확인, 호스트 이름이 없는 공개 주소를 Team Cymru에 묻는 네트워크 소유자 조회, 그리고 Flowlight가 확장 대신 카운터를
- 읽고 있는 동안 이름을 보지 못한 주소에 대한 역방향 DNS 조회입니다. 앞의 둘은 끄는 스위치가 있습니다. 셋째는 없습니다.
- 샘플러에서는 연결에 이름이 붙는 길이 대부분 그것이기 때문입니다. 그 조회는 Mac이 이미 쓰는 리졸버로 가고, 확장이 이어받아
- 트래픽 자체에서 이름을 붙이기 시작하면 멈춥니다. 호스팅 모델로 하는 질문은 그 셋 가운데 어느 것보다도 훨씬 많은 것을
- 보내지만, 직접 설정할 때까지 꺼져 있는 유일한 것입니다.
+
트래픽 데이터베이스는 업로드되지 않습니다. 개인정보 페이지에는 릴리스와 서명된 규칙 팩 확인, 선택 사항인 네트워크 소유자 조회, 샘플러의 역방향 DNS 등 모든 외부 요청이 나열됩니다. 앞의 세 항목에는 끄기 스위치가 있습니다. 역방향 DNS는 대부분의 연결에 이름을 붙이는 데 필요해 스위치가 없습니다. 호스팅 모델은 훨씬 더 많은 정보를 보내며 설정하기 전에는 꺼져 있습니다.
업데이트
릴리스는 Developer ID로 서명되고 공증됩니다. 업데이터는 공개된 체크섬을 요구하고 그것이 없는 내려받기를 거부하며, 그다음 설치
전에 내려받은 앱이 실행 중인 사본과 같은 팀의 서명인지 확인합니다. 다른 사람이 서명한 업데이트는 나머지가 다 유효해도
거부됩니다.
+
공식 규칙 팩은 용도를 한정한 별도의 서명과 하나의 고정 HTTPS 소스를 사용합니다. 앱은 독립적인 최고 시퀀스 워터마크를 유지하고, 시작할 때 캐시된 정확한 바이트를 다시 검증하며, 롤백과 서로 상충하는 콘텐츠의 제시를 거부합니다. 또한 게시자 서명을 변경된 콘텐츠의 차단 적용을 허용하는 권한으로 취급하지 않습니다. 새 팩이나 변경된 팩에는 정확한 콘텐츠를 명시적으로 검토하고 저장하는 절차가 필요합니다. 오래된 승인 콘텐츠에는 마지막 정상 버전이라는 표시가 붙으며, 신뢰가 취소되었거나 바이트가 손상된 경우에는 안전을 위해 거부됩니다.
A standard HTTPS request for the latest release; GitHub sees your IP address and the Flowlight/<version> user agent
After a clean quit and relaunch, and daily. Off switch in Settings › General
Update download
github.com
The disk image and its checksum file
Only when you click Download & Open
+
Official rule-pack update
flowlight.xinbetween.com
A standard HTTPS request to the fixed official feed for a publisher-signed catalog. The server sees your IP address, the Flowlight/<version> user agent, and conditional cache validators; it receives no traffic, pack settings, or device identifier
After startup when due and then at most daily while healthy. On by default; off switch and manual Check Now in Rules › Packs
Reverse DNS
Your configured DNS resolver
A lookup of an IP address's registered name, when no hostname was seen
As needed, in the sampler capture mode, which is the default until the Network Extension is running. No off switch: without it the sampler cannot name most connections. The Network Extension names them from the traffic itself and does not do this
Network owner lookup
Team Cymru, over DNS
Public IP addresses with no known hostname. Private and reserved addresses never leave your Mac
On by default. Off switch in Capture › Identify network owners
Ask with a hosted model
The provider you select: Anthropic, OpenAI, Google Gemini or a compatible endpoint
Your question and the results returned by the read-only Flowlight queries selected by the model. The traffic database itself is not uploaded
Only when you select a hosted provider and submit a question. The Ask screen shows the transmitted requests
é segura. O ecrã indica também o tráfego mais antigo que conseguiu ver: uma regra testada contra duas horas de histórico foi
testada contra duas horas de histórico.
+
Pacotes geridos de publicidade e rastreadores
+
Regras › Pacotes contém três listas oficiais de nomes de anfitrião: Publicidade, Rastreio entre sites e Privacidade e telemetria. Estão visíveis desde o início e todas desativadas por predefinição. O bloqueio requer a Extensão de rede; o amostrador nettop apenas pode observar.
+
Apenas atualizações oficiais e assinadas. O Flowlight consulta uma única fonte HTTPS fixa para procurar catálogos mais recentes assinados pelo editor. As verificações automáticas estão ativas por predefinição e podem ser desativadas aqui. Uma assinatura autentica o conteúdo, mas não o ativa: um novo pacote permanece desativado e, se os destinos forem alterados, um pacote ativo deixa de ser aplicado até que reveja a nova versão exata e prima Guardar. Durante uma interrupção, o conteúdo aceite pode permanecer disponível como a última versão válida conhecida, visivelmente assinalada como desatualizada. Estes são bloqueios de rede ao nível do nome de anfitrião: não podem remover espaços vazios no DOM ou no esquema, sobreposições do navegador, janelas pop-up geradas por scripts ou janelas de aplicações nativas.
+
Listas de permissões
Selecione um agente em Agentes de IA e use a Lista de permissões no seu detalhe para dizer onde pode ligar-se. Introduza domínios
(subdomínios incluídos), endereços IP ou intervalos CIDR, ou escolha uma predefinição: GitHub, npm, PyPI, Homebrew, Docker Hub, Rust crates, módulos de Go ou
@@ -616,10 +620,7 @@
Perguntas e respostas
Mostra que extensões estão instaladas, se o macOS realmente iniciou o filtro do Flowlight e há quanto tempo foi registado algo.
Cole isso num problema.
O Flowlight envia algo sobre mim para algum lado?
-
Não há conta, nem telemetria, nem analítica, e a sua base de dados de tráfego nunca é enviada. Três pedidos saem do Mac, todos listados na
- página de privacidade: a procura diária de atualizações e a consulta opcional do proprietário da rede, ambas com
- interruptores para desativar, e o DNS inverso enquanto o Flowlight está no amostrador, que não tem nenhum — sem ele, o amostrador não conseguiria
- dar nome à maioria das ligações.
+
Não há conta, nem telemetria, nem analítica, e a sua base de dados de tráfego nunca é enviada. A página de privacidade lista cada pedido: as verificações de versão e de pacotes de regras assinados, além da consulta opcional do proprietário da rede, têm interruptores; o DNS inverso só funciona com o amostrador e não tem um, porque sem ele a maioria das ligações ficaria sem nome.
Vai tornar o meu Mac mais lento ou gastar a bateria?
Com a janela fechada, o Flowlight quase não faz nada: mantém as taxas na barra de menus e escreve uma linha por segundo. A extensão de rede
custa menos do que o amostrador de reserva, que inicia um processo nettop de vida curta a cada segundo.
Cursor ⇥ raw.githubusercontent.com · recusado 4 vezes · dias de semana 18:00–09:00
+
⛔ Por opção
Pacotes geridos de publicidade e rastreadores
Estão integrados três pacotes oficiais, visíveis e desativados por predefinição. O Flowlight consulta um único feed HTTPS fixo para obter atualizações assinadas; os conteúdos novos ou alterados ficam fora da aplicação até rever essa versão exata e premir Guardar. Durante uma indisponibilidade, os conteúdos verificados e aceites podem continuar disponíveis como a última versão válida conhecida, visivelmente assinalada como desatualizada.
Bloqueiam por nome de anfitrião, pelo que não conseguem remover anúncios próprios, espaços vazios no DOM ou no esquema da página, sobreposições do navegador, janelas emergentes criadas por scripts nem janelas nativas das aplicações.
⛔ Recusa
Proteções nas ferramentas de um agente
@@ -366,7 +367,7 @@
Um monitor que não tem de monitorizar
Fica no seu Mac
Um único ficheiro SQLite local. Sem conta, sem sincronização na nuvem, sem telemetria.
Metadados por predefinição
Nomes de anfitrião, portas e contagens de bytes. Desencriptar HTTPS é um modo separado que tem de ativar, e fica tudo no seu Mac.
Filtrado no núcleo
A captura só deixa passar as respostas de DNS e os ClientHello de TLS. Todo o resto é descartado antes de o Flowlight o ver.
-
Consultas sob o seu controlo
Saber quem é o proprietário de um endereço envia à Team Cymru apenas IPs públicos sem nome de anfitrião, e desativa-se num clique. A procura diária de atualizações só pergunta ao GitHub pela versão mais recente e também pode ser desativada.
+
Consultas sob o seu controlo
Saber quem é o proprietário de um endereço envia à Team Cymru apenas IPs públicos sem nome de anfitrião. As verificações de versões e de pacotes de regras assinados enviam pedidos HTTPS normais, sem tráfego nem definições dos pacotes. Cada uma tem o seu próprio interruptor.
Um pedido HTTPS normal pela versão mais recente; o GitHub vê o seu endereço IP e o agente de utilizador Flowlight/<version>
Na abertura e uma vez por dia, a partir da 0.1.2. Interruptor para desativar em Definições › Geral
Transferência da atualização
github.com
A imagem de disco e o seu ficheiro de soma de verificação
Só quando clica em Transferir e abrir
+
Atualização oficial do pacote de regras
flowlight.xinbetween.com
Um pedido HTTPS padrão ao feed oficial fixo. O servidor vê o seu endereço IP, o agente do utilizador Flowlight/<version> e os validadores condicionais de cache; não recebe tráfego, definições dos pacotes nem qualquer identificador do dispositivo
Após o arranque, quando for necessário, e depois, no máximo, uma vez por dia enquanto funcionar normalmente. Ativa por predefinição; opção para desativar e Procurar agora manualmente em Regras › Pacotes
DNS inverso
O resolvedor de DNS que tem configurado
Uma consulta do nome registado de um endereço IP, quando não foi visto nenhum nome de anfitrião
Conforme necessário, no modo de captura por amostrador, que é a predefinição até a extensão de rede estar a correr. Sem interruptor para desligar: sem isto, o amostrador não consegue dar nome à maioria das ligações. A extensão de rede dá-lhes nome a partir do próprio tráfego e não faz esta consulta
Consulta do proprietário da rede
Team Cymru, por DNS
Endereços IP públicos sem nome de anfitrião conhecido. Os endereços privados e reservados nunca saem do seu Mac
Ativada por predefinição. Interruptor para desativar em Captura › Identificar os proprietários da rede
Perguntar com um modelo alojado
O fornecedor que escolher: Anthropic, OpenAI, Google Gemini ou um endpoint compatível
A sua pergunta e os resultados devolvidos pelas consultas de leitura do Flowlight escolhidas pelo modelo. A base de dados de tráfego em si não é enviada
Só quando escolhe um fornecedor alojado e submete uma pergunta. O ecrã Perguntar mostra os pedidos transmitidos
pedidos e das respostas durante três dias — a coisa mais sensível que chega a manter. Os cabeçalhos de credenciais são ocultados por nome e
por palavra, o que é uma heurística e não uma garantia; um cabeçalho que leve um segredo sob um nome que nada reconhece pode ficar
registado.
-
A base de dados de tráfego nunca é enviada. Há, ainda assim, três coisas mais pequenas que saem do Mac sem lhe serem pedidas,
- todas nomeadas na página de privacidade: a procura de atualizações, que pede ao GitHub a versão mais
- recente no arranque e uma vez por dia; a consulta do proprietário da rede, que pergunta ao Team Cymru por endereços públicos sem
- nome de anfitrião; e, enquanto o Flowlight lê contadores em vez de correr a sua extensão, uma consulta de DNS inverso dos endereços
- para os quais não viu nome. As duas primeiras têm interruptores para desligar. A terceira não tem, porque no amostrador é quase
- tudo aquilo que dá um nome a uma ligação — vai para o resolvedor de DNS que o Mac já usa e para quando a extensão assume e passa a
- nomear as ligações a partir do próprio tráfego. Perguntar com um modelo alojado envia muito mais do que qualquer uma delas, e é a
- única coisa que fica desligada até a configurar.
+
A base de dados de tráfego nunca é enviada. A página de privacidade lista cada pedido externo: verificações de versão e de pacotes de regras assinados, consulta opcional do proprietário da rede e DNS inverso com o amostrador. Os três primeiros têm interruptores; o DNS inverso não, porque sem ele o amostrador não conseguiria nomear a maioria das ligações. Um modelo alojado envia muito mais e fica desligado até o configurar.
Atualizações
As versões são assinadas com um Developer ID e notarizadas. O atualizador exige uma soma de verificação publicada e recusa uma
transferência sem ela; depois verifica que a app transferida está assinada pela mesma equipa que a cópia em execução antes de a instalar. Uma
atualização assinada por outra pessoa é recusada mesmo que no resto seja válida.
+
Os pacotes de regras oficiais usam uma assinatura distinta e limitada a esta finalidade e uma única fonte HTTPS fixa. A aplicação mantém separadamente um marcador da sequência mais alta, volta a verificar no arranque os bytes exatos guardados em cache, rejeita regressões e publicações contraditórias e nunca trata a assinatura do editor como autorização para aplicar conteúdos alterados. Os pacotes novos ou alterados exigem a revisão explícita do conteúdo exato e Guardar; os conteúdos desatualizados já aceites são assinalados como a última versão válida conhecida, enquanto a revogação da confiança ou bytes corrompidos fazem com que o conteúdo seja recusado por segurança.
Comunicar uma vulnerabilidade
Use a comunicação privada de vulnerabilidades do GitHub, ou envie um email
diff --git a/site/pages/releases.html b/site/pages/releases.html
index 49aa71ff..c04438fc 100644
--- a/site/pages/releases.html
+++ b/site/pages/releases.html
@@ -13,7 +13,25 @@
What's new
-
0.15.3
Latest
+
0.15.4
Latest
+
+
Consume verified official rule-pack updates. Flowlight checks its one fixed HTTPS feed with conditional
+ requests, verifies publisher signatures and compatibility before persistence, and keeps exact last-known-good bytes plus
+ an independent anti-rollback watermark.
+
Keep every policy change consentful. New packs stay off. Changed versions or destinations immediately leave
+ enforcement while preserving scope, selected apps, exceptions and history; explicit review and Save of the exact new content
+ restores consent, and a refresh racing an open editor makes its old Save stale.
+
Expose trust and failure state without overclaiming. Rules › Packs now shows automatic-refresh controls,
+ verification status, sequence, source, signer, timestamps, bundled-versus-downloaded provenance, stale last-known-good, and
+ independent fetch/apply failures. Refresh sends no traffic, pack settings or device identifier.
+
Fail closed at startup and on the wire. Capture waits for catalog verification plus saved exceptions;
+ redirects, MIME, size, timeout, rollback, equivocation, revoked trust, corrupt cache and failed database commits cannot publish
+ unseen definitions or replace the active snapshot.
+
+
+
+
+
0.15.3
Publish official rule packs as signed data. Flowlight now defines a purpose-scoped P-256
signature contract for its official managed-pack catalog, with the public verification key included in the
diff --git a/site/pages/threat-model.html b/site/pages/threat-model.html
index 74d3ad73..0176b605 100644
--- a/site/pages/threat-model.html
+++ b/site/pages/threat-model.html
@@ -102,18 +102,21 @@
What Flowlight itself holds
Traffic metadata in a local SQLite database, with published retention. With HTTPS inspection on it also holds request and
response bodies for three days — the most sensitive thing it ever keeps. Credential headers are redacted by name and by word,
which is a heuristic and not a guarantee; a header carrying a secret under a name nothing recognises can be recorded.
-
The traffic database is never uploaded. Three smaller things do leave the Mac without being asked for, all of them named on
- the privacy page: the update check, which asks GitHub for the latest release on launch and
- daily; the network-owner lookup, which asks Team Cymru about public addresses that have no hostname; and, while Flowlight is
- reading counters instead of running its extension, a reverse-DNS lookup of addresses it saw no name for. The first two have
- off switches. The third does not, because on the sampler it is most of how a connection gets a name at all — it goes to the
- resolver the Mac already uses, and it stops once the extension takes over and names connections from the traffic itself.
- Ask with a hosted model sends far more than any of them, and is the one thing that stays off until you configure it.
+
The traffic database is never uploaded. Four smaller things do leave the Mac without being asked for, all of them named on
+ the privacy page: the release update check; the fixed official rule-pack feed, which receives
+ no traffic or pack settings; the network-owner lookup, which asks Team Cymru about public addresses with no hostname; and,
+ while Flowlight is reading counters instead of running its extension, reverse DNS through the Mac's configured resolver.
+ The first three have off switches. Reverse DNS does not, because on the sampler it is most of how a connection gets a name;
+ it stops once the extension takes over. Ask with a hosted model sends far more than any of them, and stays off until configured.
Updates
Releases are signed with a Developer ID and notarized. The updater requires a published checksum and refuses a download
without one, then verifies that the downloaded app is signed by the same team as the running copy before installing it. An
update signed by somebody else is refused even if it is otherwise valid.
+
Official rule packs use a separate purpose-scoped P-256 signature and one compiled HTTPS source. The app keeps an independent
+ highest-sequence watermark, reverifies exact cached bytes at launch, rejects rollback and equivocation, and never treats a
+ publisher signature as permission to enforce changed content. New or changed packs require explicit exact-content review and
+ Save; stale accepted content is labeled last-known-good, while revoked trust or corrupt bytes fail closed.
Reporting a vulnerability
Use GitHub's private vulnerability reporting, or email
diff --git a/site/pages/zh-Hans/docs.html b/site/pages/zh-Hans/docs.html
index f1f6791f..2c871008 100644
--- a/site/pages/zh-Hans/docs.html
+++ b/site/pages/zh-Hans/docs.html
@@ -346,6 +346,10 @@
請使用 GitHub 的私密漏洞回報,或者如果你不想用
From e4a2f1b2a56fb400e454d3b0477fb22c069402e4 Mon Sep 17 00:00:00 2001
From: blessdyb
Date: Tue, 20 Oct 2026 21:25:21 -0700
Subject: [PATCH 08/14] Harden catalog state publication
Bind app-owned validation to the running version, restore durable pack state across catalog omission, and prevent stale refresh results from overwriting newer local choices or cleared activity. Keep previews truthful across overlapping packs and reject AppleDouble package payloads before notarization.
Co-Authored-By: Claude Code
---
.../Analysis/RulePackCatalogComposition.swift | 7 +-
Flowlight/Analysis/RulePackCatalogState.swift | 5 +
.../Analysis/RulePackCatalogUpdater.swift | 62 ++++-
Flowlight/Analysis/RulePackStore.swift | 37 ++-
Flowlight/App/TrafficMonitor.swift | 3 +-
Flowlight/Storage/TrafficDatabase.swift | 10 +-
Flowlight/UI/RulePacksView.swift | 4 +-
FlowlightExtension/Info.plist | 7 +-
.../RulePackCatalogPersistenceTests.swift | 17 +-
.../RulePackCatalogUpdaterTests.swift | 12 +-
FlowlightTests/RulePackTests.swift | 219 +++++++++++++++++-
.../TrafficMonitorRulePackStartupTests.swift | 2 +-
Shared/RulePack.swift | 13 +-
scripts/build-pkg.sh | 7 +
14 files changed, 362 insertions(+), 43 deletions(-)
diff --git a/Flowlight/Analysis/RulePackCatalogComposition.swift b/Flowlight/Analysis/RulePackCatalogComposition.swift
index f72bbbc5..82658d60 100644
--- a/Flowlight/Analysis/RulePackCatalogComposition.swift
+++ b/Flowlight/Analysis/RulePackCatalogComposition.swift
@@ -4,9 +4,10 @@ enum RulePackCatalogComposition {
/// The signed feed may replace a built-in with the same stable ID or add an official pack. Omission is not a
/// tombstone: every built-in remains available as the signed-app emergency baseline.
static func compose(_ catalog: VerifiedRulePackCatalog,
+ currentAppVersion: String,
builtIns: [RulePackDefinition] = BuiltInRulePacks.all) throws -> VerifiedRulePackCatalog {
- try RulePackValidator.validate(builtIns)
- try RulePackValidator.validate(catalog.definitions)
+ try RulePackValidator.validate(builtIns, currentAppVersion: currentAppVersion)
+ try RulePackValidator.validate(catalog.definitions, currentAppVersion: currentAppVersion)
var merged: [String: RulePackDefinition] = [:]
for definition in builtIns {
@@ -23,7 +24,7 @@ enum RulePackCatalogComposition {
var composed = catalog
composed.definitions = merged.values.sorted { $0.id < $1.id }
- try RulePackValidator.validate(composed.definitions)
+ try RulePackValidator.validate(composed.definitions, currentAppVersion: currentAppVersion)
return composed
}
}
diff --git a/Flowlight/Analysis/RulePackCatalogState.swift b/Flowlight/Analysis/RulePackCatalogState.swift
index b99498d9..b690d65f 100644
--- a/Flowlight/Analysis/RulePackCatalogState.swift
+++ b/Flowlight/Analysis/RulePackCatalogState.swift
@@ -63,4 +63,9 @@ struct RulePackCatalogAcceptance: Equatable, Sendable {
var state: RulePackCatalogState
/// All saved rows, including dormant IDs omitted by the currently active catalog.
var configurations: [RulePackConfiguration]
+ /// Durable user state is returned with every accepted catalog so an omitted and later reintroduced pack cannot
+ /// resume enforcement without the exceptions and history that still belong to its stable ID.
+ var overrides: [RulePackOverride]
+ var events: [RulePackEventRecord]
+ var deliveryStats: RulePackDeliveryStats
}
diff --git a/Flowlight/Analysis/RulePackCatalogUpdater.swift b/Flowlight/Analysis/RulePackCatalogUpdater.swift
index 1f9cf016..28eaa1b8 100644
--- a/Flowlight/Analysis/RulePackCatalogUpdater.swift
+++ b/Flowlight/Analysis/RulePackCatalogUpdater.swift
@@ -18,6 +18,7 @@ final class RulePackCatalogUpdater: ObservableObject {
case wrongMIME(String)
case responseTooLarge
case missingCache
+ case rejectedCatalogInstall
var description: String {
switch self {
@@ -27,6 +28,7 @@ final class RulePackCatalogUpdater: ObservableObject {
case .wrongMIME(let value): return "The official catalog returned an unsupported content type: \(value)."
case .responseTooLarge: return "The official catalog exceeded the 8 MiB size limit."
case .missingCache: return "The server reported no change, but no verified cached catalog is available."
+ case .rejectedCatalogInstall: return "The committed official catalog could not be installed."
}
}
}
@@ -107,7 +109,7 @@ final class RulePackCatalogUpdater: ObservableObject {
receipt: receipt,
allowExpiredLastKnownGood: true
)
- let composed = try RulePackCatalogComposition.compose(verified)
+ let composed = try RulePackCatalogComposition.compose(verified, currentAppVersion: currentAppVersion)
store.installCommittedCatalog(composed, acceptance: bootstrap)
recoveryNeeded = false
status = verified.stale ? .stale : .current
@@ -118,7 +120,7 @@ final class RulePackCatalogUpdater: ObservableObject {
}
let verified = try verify(try bundledCatalog(), receipt: nil, allowExpiredLastKnownGood: false)
- let composed = try RulePackCatalogComposition.compose(verified)
+ let composed = try RulePackCatalogComposition.compose(verified, currentAppVersion: currentAppVersion)
let checkedAt = now()
let acceptance = try await db.asyncResult {
try $0.acceptVerifiedRulePackCatalog(
@@ -198,8 +200,9 @@ final class RulePackCatalogUpdater: ObservableObject {
try validateJSONResponse(http, data: data)
let prior = catalogState.receipt
let verified = try verify(data, receipt: prior, allowExpiredLastKnownGood: false)
- let composed = try RulePackCatalogComposition.compose(verified)
- let acceptance = try await db.asyncResult {
+ let composed = try RulePackCatalogComposition.compose(verified, currentAppVersion: currentAppVersion)
+ let storeRevision = store.catalogInstallRevision
+ var acceptance = try await db.asyncResult {
try $0.acceptVerifiedRulePackCatalog(
composed,
etag: http.value(forHTTPHeaderField: "ETag"),
@@ -208,9 +211,15 @@ final class RulePackCatalogUpdater: ObservableObject {
nextEligibleAt: attempt.addingTimeInterval(Self.healthyInterval)
)
}
- guard generation == requestGeneration else { return }
+ guard generation == requestGeneration,
+ let installed = try await installCommittedCatalog(
+ composed,
+ acceptance: acceptance,
+ expectedRevision: storeRevision,
+ generation: generation
+ ) else { return }
+ acceptance = installed
catalogState = acceptance.state
- store.installCommittedCatalog(composed, acceptance: acceptance)
recoveryNeeded = false
status = .current
@@ -223,7 +232,8 @@ final class RulePackCatalogUpdater: ObservableObject {
receipt: receipt,
allowExpiredLastKnownGood: true
)
- let composed = try RulePackCatalogComposition.compose(verified)
+ let composed = try RulePackCatalogComposition.compose(verified, currentAppVersion: currentAppVersion)
+ let storeRevision = store.catalogInstallRevision
try await db.asyncResult {
try $0.recordRulePackCatalogNotModified(
catalogID: RulePackCatalogVerifier.catalogID,
@@ -237,8 +247,14 @@ final class RulePackCatalogUpdater: ObservableObject {
let acceptance = try await db.asyncResult {
try $0.loadRulePackCatalogBootstrap(catalogID: RulePackCatalogVerifier.catalogID)
}
- catalogState = acceptance.state
- store.installCommittedCatalog(composed, acceptance: acceptance)
+ guard generation == requestGeneration,
+ let installed = try await installCommittedCatalog(
+ composed,
+ acceptance: acceptance,
+ expectedRevision: storeRevision,
+ generation: generation
+ ) else { return }
+ catalogState = installed.state
status = verified.stale ? .stale : .current
default:
@@ -252,6 +268,34 @@ final class RulePackCatalogUpdater: ObservableObject {
}
}
+ private func installCommittedCatalog(
+ _ catalog: VerifiedRulePackCatalog,
+ acceptance initialAcceptance: RulePackCatalogAcceptance,
+ expectedRevision initialRevision: Int,
+ generation: Int
+ ) async throws -> RulePackCatalogAcceptance? {
+ var acceptance = initialAcceptance
+ var expectedRevision = initialRevision
+ while generation == requestGeneration {
+ switch store.installCommittedCatalog(
+ catalog,
+ acceptance: acceptance,
+ expectedRevision: expectedRevision
+ ) {
+ case .installed:
+ return acceptance
+ case .rejected:
+ throw UpdateError.rejectedCatalogInstall
+ case .staleRevision:
+ expectedRevision = store.catalogInstallRevision
+ acceptance = try await db.asyncResult {
+ try $0.loadRulePackCatalogBootstrap(catalogID: RulePackCatalogVerifier.catalogID)
+ }
+ }
+ }
+ return nil
+ }
+
private func makeRequest(useValidators: Bool) -> URLRequest {
var request = URLRequest(url: sourceURL)
request.httpMethod = "GET"
diff --git a/Flowlight/Analysis/RulePackStore.swift b/Flowlight/Analysis/RulePackStore.swift
index 07d2f351..74f70b0a 100644
--- a/Flowlight/Analysis/RulePackStore.swift
+++ b/Flowlight/Analysis/RulePackStore.swift
@@ -25,6 +25,12 @@ final class RulePackStore: ObservableObject {
var succeeded: Bool { self == .saved }
}
+ enum CatalogInstallResult: Equatable, Sendable {
+ case installed
+ case staleRevision
+ case rejected
+ }
+
@Published private(set) var configurations: [RulePackConfiguration] = []
@Published private(set) var overrides: [RulePackOverride] = []
@Published private(set) var events: [RulePackEventRecord] = []
@@ -39,10 +45,13 @@ final class RulePackStore: ObservableObject {
private var catalogReceipt: RulePackCatalogReceipt?
private var committedSnapshot: RulePackSnapshot?
private var revision = 0
+ private let currentAppVersion: String
- init(definitions: [RulePackDefinition] = BuiltInRulePacks.all) {
+ init(definitions: [RulePackDefinition] = BuiltInRulePacks.all,
+ currentAppVersion: String = ExtensionManager.appVersion) {
self.definitions = definitions.sorted { $0.id < $1.id }
- do { try RulePackValidator.validate(self.definitions) }
+ self.currentAppVersion = currentAppVersion
+ do { try RulePackValidator.validate(self.definitions, currentAppVersion: currentAppVersion) }
catch {
definitionsValid = false
status = .failed("Built-in pack validation failed: \(error)")
@@ -58,6 +67,7 @@ final class RulePackStore: ObservableObject {
}
var snapshot: RulePackSnapshot? { committedSnapshot }
+ var catalogInstallRevision: Int { revision }
func configuration(for packID: String) -> RulePackConfiguration {
configurations.first { $0.packID == packID } ?? RulePackConfiguration(packID: packID)
@@ -126,19 +136,22 @@ final class RulePackStore: ObservableObject {
publish()
}
- /// Installs definitions only after `acceptVerifiedRulePackCatalog` committed them. A newer local mutation bumps
- /// the revision first, so an older asynchronous install result cannot overwrite it.
+ /// Installs definitions only after `acceptVerifiedRulePackCatalog` committed them. The optional revision is a
+ /// compare-and-swap guard: a local save that publishes while catalog I/O is suspended makes captured state stale.
+ @discardableResult
func installCommittedCatalog(_ catalog: VerifiedRulePackCatalog,
- acceptance: RulePackCatalogAcceptance) {
- do { try RulePackValidator.validate(catalog.definitions) }
+ acceptance: RulePackCatalogAcceptance,
+ expectedRevision: Int? = nil) -> CatalogInstallResult {
+ guard expectedRevision == nil || expectedRevision == revision else { return .staleRevision }
+ do { try RulePackValidator.validate(catalog.definitions, currentAppVersion: currentAppVersion) }
catch {
status = .failed("Verified pack validation failed: \(error)")
- return
+ return .rejected
}
guard acceptance.state.receipt?.sequence == catalog.sequence,
acceptance.state.receipt?.digest == catalog.digest else {
status = .failed("Verified catalog receipt does not match committed storage.")
- return
+ return .rejected
}
revision += 1
definitions = catalog.definitions.sorted { $0.id < $1.id }
@@ -146,9 +159,11 @@ final class RulePackStore: ObservableObject {
catalogReceipt = acceptance.state.receipt
configurations = effectiveConfigurations(from: acceptance.configurations)
let known = Set(definitions.map(\.id))
- overrides = overrides.filter { known.contains($0.packID) }
- events = events.filter { known.contains($0.packID) }
+ overrides = acceptance.overrides.filter { known.contains($0.packID) }
+ events = acceptance.events.filter { known.contains($0.packID) }
+ deliveryStats = acceptance.deliveryStats
publish()
+ return .installed
}
/// Receipt-free writes can only narrow enforcement. Every enabling path must use the immutable-review overload.
@@ -283,6 +298,7 @@ final class RulePackStore: ObservableObject {
let events = try db.rulePackEvents(limit: 300)
let deliveryStats = try db.rulePackDeliveryStats()
Task { @MainActor in
+ self?.revision += 1
self?.events = events
self?.deliveryStats = deliveryStats
self?.version += 1
@@ -298,6 +314,7 @@ final class RulePackStore: ObservableObject {
func events(for packID: String) -> [RulePackEventRecord] { events.filter { $0.packID == packID } }
func clearActivity() {
+ revision += 1
events = []
deliveryStats = RulePackDeliveryStats(droppedHits: 0, droppedKeys: 0)
version += 1
diff --git a/Flowlight/App/TrafficMonitor.swift b/Flowlight/App/TrafficMonitor.swift
index 3e120e2a..925e86e7 100644
--- a/Flowlight/App/TrafficMonitor.swift
+++ b/Flowlight/App/TrafficMonitor.swift
@@ -55,7 +55,7 @@ final class TrafficMonitor: ObservableObject {
/// Everything Flowlight has been told to block or allow, and what those rules have done.
let rules = RuleStore()
/// Official managed destination lists, kept separate from human-authored rules and disabled by default.
- let rulePacks = RulePackStore()
+ let rulePacks: RulePackStore
/// Signed official catalog bootstrap, cache re-verification, and bounded refresh state.
private(set) var rulePackCatalog: RulePackCatalogUpdater!
/// Which tools each agent may use, which is a different question from which hosts it may reach.
@@ -109,6 +109,7 @@ final class TrafficMonitor: ObservableObject {
mode = CaptureMode(rawValue: UserDefaults.standard.string(forKey: AnomalySettings.Keys.captureMode) ?? "") ?? .nettop
self.trafficSourceFactory = trafficSourceFactory
catalogTransportInjected = catalogTransport != nil
+ rulePacks = RulePackStore(currentAppVersion: ExtensionManager.appVersion)
do {
if let databaseURL {
db = try TrafficDatabase(url: databaseURL)
diff --git a/Flowlight/Storage/TrafficDatabase.swift b/Flowlight/Storage/TrafficDatabase.swift
index 76e38050..5ed19734 100644
--- a/Flowlight/Storage/TrafficDatabase.swift
+++ b/Flowlight/Storage/TrafficDatabase.swift
@@ -1107,7 +1107,10 @@ final class TrafficDatabase: @unchecked Sendable {
func loadRulePackCatalogBootstrap(catalogID: String) throws -> RulePackCatalogAcceptance {
RulePackCatalogAcceptance(
state: try loadRulePackCatalogState(catalogID: catalogID),
- configurations: try loadRulePackConfigurationsStrictly()
+ configurations: try loadRulePackConfigurationsStrictly(),
+ overrides: try loadRulePackOverrides(),
+ events: try rulePackEvents(limit: 300),
+ deliveryStats: try rulePackDeliveryStats()
)
}
@@ -1223,7 +1226,10 @@ final class TrafficDatabase: @unchecked Sendable {
let state = try loadRulePackCatalogState(catalogID: catalogID)
return RulePackCatalogAcceptance(
state: state,
- configurations: reconciled.sorted { $0.packID < $1.packID }
+ configurations: reconciled.sorted { $0.packID < $1.packID },
+ overrides: try loadRulePackOverrides(),
+ events: try rulePackEvents(limit: 300),
+ deliveryStats: try rulePackDeliveryStats()
)
}
diff --git a/Flowlight/UI/RulePacksView.swift b/Flowlight/UI/RulePacksView.swift
index 5b338ad6..b0e5602a 100644
--- a/Flowlight/UI/RulePacksView.swift
+++ b/Flowlight/UI/RulePacksView.swift
@@ -413,7 +413,7 @@ private struct RulePackEditor: View {
private var destinations: some View {
DisclosureGroup(L("%lld destinations", pack.entries.count)) {
- VStack(alignment: .leading, spacing: 3) {
+ LazyVStack(alignment: .leading, spacing: 3) {
ForEach(pack.entries) { entry in
Text(entry.host).font(.caption.monospaced()).textSelection(.enabled)
}
@@ -520,7 +520,7 @@ private struct RulePackEditor: View {
packVersion == packs.version,
ruleVersion == rules.version,
dataVersion == monitor.dataVersion else { return }
- preview = RulePackSimulation.run(candidate: candidate, definitions: [pack],
+ preview = RulePackSimulation.run(candidate: candidate, definitions: packs.definitions,
configurations: packs.configurations,
overrides: packs.overrides, rules: rules.rules,
policies: input.1, paused: rules.isPaused,
diff --git a/FlowlightExtension/Info.plist b/FlowlightExtension/Info.plist
index 2a1cd533..6bf31fca 100644
--- a/FlowlightExtension/Info.plist
+++ b/FlowlightExtension/Info.plist
@@ -20,11 +20,12 @@
content filter at a time and holds superseded copies until a restart, so changing the extension's version
on every app release forced a reboot each time. Kept fixed, an app-only update ships the version macOS
already runs and replaces nothing. Bump these two ONLY when the FlowlightExtension binary itself changes
- (and expect one replacement when you do). 0.15.0 adds managed rule-pack enforcement and IPC. -->
+ (and expect one replacement when you do). 0.15.4 keeps app-version admission app-side while the extension
+ validates the independently versioned snapshot protocol. -->
CFBundleShortVersionString
- 0.15.0
+ 0.15.4CFBundleVersion
- 23
+ 24LSMinimumSystemVersion$(MACOSX_DEPLOYMENT_TARGET)FLAppGroupIdentifier
diff --git a/FlowlightTests/RulePackCatalogPersistenceTests.swift b/FlowlightTests/RulePackCatalogPersistenceTests.swift
index 6ec72c7d..94d26e56 100644
--- a/FlowlightTests/RulePackCatalogPersistenceTests.swift
+++ b/FlowlightTests/RulePackCatalogPersistenceTests.swift
@@ -16,7 +16,7 @@ final class RulePackCatalogPersistenceTests: XCTestCase {
definitions: [added, replacement]
)
- let composed = try RulePackCatalogComposition.compose(catalog)
+ let composed = try RulePackCatalogComposition.compose(catalog, currentAppVersion: "0.15.4")
XCTAssertEqual(composed.definitions.map(\.id),
["official.added", "official.advertising", "official.telemetry", "official.tracking"])
@@ -27,6 +27,21 @@ final class RulePackCatalogPersistenceTests: XCTestCase {
XCTAssertEqual(composed.digest, catalog.digest)
}
+ func testCompositionUsesRunningAppVersionForCompatibleCatalog() throws {
+ var definition = BuiltInRulePacks.advertising
+ definition.manifest.minimumAppVersion = "0.15.4"
+ let catalog = verifiedCatalog(
+ sequence: 2,
+ digest: String(repeating: "a", count: 64),
+ definitions: [definition]
+ )
+
+ XCTAssertNoThrow(try RulePackCatalogComposition.compose(catalog, currentAppVersion: "0.15.4"))
+ XCTAssertThrowsError(try RulePackCatalogComposition.compose(catalog, currentAppVersion: "0.15.3")) {
+ XCTAssertEqual($0 as? RulePackValidationError, .incompatibleProtocol(definition.id))
+ }
+ }
+
func testCatalogAcceptancePersistsExactBytesAndSurvivesReopen() throws {
let url = temporaryDatabaseURL("catalog-reopen")
var db: TrafficDatabase? = try TrafficDatabase(url: url)
diff --git a/FlowlightTests/RulePackCatalogUpdaterTests.swift b/FlowlightTests/RulePackCatalogUpdaterTests.swift
index c38fa25a..4032e05b 100644
--- a/FlowlightTests/RulePackCatalogUpdaterTests.swift
+++ b/FlowlightTests/RulePackCatalogUpdaterTests.swift
@@ -67,9 +67,11 @@ final class RulePackCatalogUpdaterTests: XCTestCase {
@MainActor
func testVerified200PersistsValidatorsAndInstallsNewPackDefaultOff() async throws {
let key = P256.Signing.PrivateKey()
- let data = try signedCatalog(key: key, sequence: 2, definitions: [newDefinition()])
+ var compatible = newDefinition()
+ compatible.manifest.minimumAppVersion = "0.15.4"
+ let data = try signedCatalog(key: key, sequence: 2, definitions: [compatible])
let db = try database("accepted-200")
- let store = RulePackStore()
+ let store = RulePackStore(currentAppVersion: "0.15.4")
let response = HTTPURLResponse(
url: try XCTUnwrap(URL(string: RulePackCatalogVerifier.sourceURL)),
statusCode: 200, httpVersion: nil,
@@ -133,7 +135,7 @@ final class RulePackCatalogUpdaterTests: XCTestCase {
data: sequenceTwo, trustedSigners: [signer(key)], now: Int64(now.timeIntervalSince1970),
currentAppVersion: "0.15.4"
)
- let composed = try RulePackCatalogComposition.compose(verified)
+ let composed = try RulePackCatalogComposition.compose(verified, currentAppVersion: "0.15.4")
let db = try database("missing-cache")
_ = try db.acceptVerifiedRulePackCatalog(
composed, etag: "stale-validator", checkedAt: now,
@@ -295,7 +297,7 @@ final class RulePackCatalogUpdaterTests: XCTestCase {
)
let db = try database("expired-lkg")
_ = try db.acceptVerifiedRulePackCatalog(
- try RulePackCatalogComposition.compose(verified), checkedAt: acceptedAt,
+ try RulePackCatalogComposition.compose(verified, currentAppVersion: "0.15.4"), checkedAt: acceptedAt,
nextEligibleAt: acceptedAt.addingTimeInterval(60)
)
let store = RulePackStore()
@@ -341,7 +343,7 @@ final class RulePackCatalogUpdaterTests: XCTestCase {
currentAppVersion: "0.15.4"
)
_ = try db.acceptVerifiedRulePackCatalog(
- try RulePackCatalogComposition.compose(verified), checkedAt: now,
+ try RulePackCatalogComposition.compose(verified, currentAppVersion: "0.15.4"), checkedAt: now,
nextEligibleAt: now.addingTimeInterval(60)
)
var revoked = signer(key)
diff --git a/FlowlightTests/RulePackTests.swift b/FlowlightTests/RulePackTests.swift
index 0a25017b..b8524eb2 100644
--- a/FlowlightTests/RulePackTests.swift
+++ b/FlowlightTests/RulePackTests.swift
@@ -90,6 +90,8 @@ final class RulePackTests: XCTestCase {
XCTAssertThrowsError(try RulePackValidator.validate([newerApp], currentAppVersion: "0.15.0")) {
XCTAssertEqual($0 as? RulePackValidationError, .incompatibleProtocol(valid.id))
}
+ XCTAssertNoThrow(try RulePackValidator.validate([newerApp]),
+ "the extension validates protocol compatibility after the app has admitted the snapshot")
}
func testSimulationReportsOnlyNewlyBlockedTraffic() throws {
@@ -108,6 +110,27 @@ final class RulePackTests: XCTestCase {
XCTAssertTrue(unchanged.isEmpty)
}
+ func testSimulationIncludesOtherEnabledPacksInBaseline() {
+ let entry = RulePackEntry(id: 1, host: "ads.example", category: .advertising)
+ let candidateDefinition = pack(id: "official.candidate", entries: [entry])
+ let activeDefinition = pack(id: "official.active", entries: [entry])
+ let candidate = RulePackConfiguration(packID: candidateDefinition.id)
+ let active = RulePackConfiguration(packID: activeDefinition.id, enabled: true)
+ let flow = RuleSimulation.Flow(
+ when: Date(), agentKey: "com.apple.Safari", bundleID: "com.apple.Safari",
+ appName: "Safari", host: "cdn.ads.example", ip: "203.0.113.7", port: 443,
+ bytes: 50, connections: 2
+ )
+
+ let result = RulePackSimulation.run(
+ candidate: candidate,
+ definitions: [activeDefinition, candidateDefinition],
+ configurations: [active], overrides: [], rules: [], flows: [flow]
+ )
+
+ XCTAssertTrue(result.isEmpty, "an already-enforced destination is not a newly refused connection")
+ }
+
func testSimulationUsesSafetyPauseRulesAndAgentPolicyPrecedence() {
let definition = pack(entries: [
RulePackEntry(id: 1, host: "ads.example", category: .advertising),
@@ -519,6 +542,63 @@ final class RulePackTests: XCTestCase {
XCTAssertEqual(store.overrides, [exception])
}
+ @MainActor
+ func testClearingActivityRejectsCatalogInstallWithCapturedEvents() async throws {
+ let url = FileManager.default.temporaryDirectory
+ .appendingPathComponent("flowlight-pack-clear-race-\(UUID().uuidString).sqlite")
+ let db = try TrafficDatabase(url: url)
+ let definition = BuiltInRulePacks.advertising
+ let catalog = VerifiedRulePackCatalog(
+ definitions: [definition], sequence: 1, digest: String(repeating: "1", count: 64),
+ signerKeyID: "test-key", stale: false, rawEnvelope: Data("one".utf8)
+ )
+ _ = try db.acceptVerifiedRulePackCatalog(
+ catalog, checkedAt: Date(timeIntervalSince1970: 100),
+ nextEligibleAt: Date(timeIntervalSince1970: 200)
+ )
+ let batch = RulePackEventBatch(events: [
+ RulePackEvent(
+ bucket: 1_700_000_000, packID: definition.id, packVersion: definition.manifest.version,
+ entryID: 1, category: .advertising, action: .block, count: 1,
+ firstAt: 1_700_000_001, lastAt: 1_700_000_001,
+ bundleID: "com.apple.Safari", appName: "Safari",
+ agentKey: "com.apple.Safari", agentName: "Safari", host: "ads.example",
+ matchedHost: "ads.example", port: 443
+ ),
+ ], droppedHits: 2, droppedKeys: 1)
+ XCTAssertTrue(try db.recordRulePackEventBatch(batch))
+
+ let store = RulePackStore(definitions: [definition], currentAppVersion: "0.15.4")
+ XCTAssertEqual(
+ store.installCommittedCatalog(
+ catalog,
+ acceptance: try db.loadRulePackCatalogBootstrap(
+ catalogID: RulePackCatalogVerifier.catalogID
+ )
+ ),
+ .installed
+ )
+ XCTAssertFalse(store.events.isEmpty)
+ let expectedRevision = store.catalogInstallRevision
+ let captured = try db.loadRulePackCatalogBootstrap(
+ catalogID: RulePackCatalogVerifier.catalogID
+ )
+
+ try db.clearAll()
+ store.clearActivity()
+
+ XCTAssertEqual(
+ store.installCommittedCatalog(
+ catalog,
+ acceptance: captured,
+ expectedRevision: expectedRevision
+ ),
+ .staleRevision
+ )
+ XCTAssertTrue(store.events.isEmpty)
+ XCTAssertEqual(store.deliveryStats, RulePackDeliveryStats(droppedHits: 0, droppedKeys: 0))
+ }
+
@MainActor
func testChangedPackRequiresFreshConsent() async throws {
let url = FileManager.default.temporaryDirectory.appendingPathComponent("flowlight-pack-consent-\(UUID().uuidString).sqlite")
@@ -644,6 +724,143 @@ final class RulePackTests: XCTestCase {
candidate.enabled = false
}
+ @MainActor
+ func testOmittedAndReintroducedPackRestoresDurableException() async throws {
+ let url = FileManager.default.temporaryDirectory
+ .appendingPathComponent("flowlight-pack-reintroduced-\(UUID().uuidString).sqlite")
+ let db = try TrafficDatabase(url: url)
+ var definition = BuiltInRulePacks.advertising
+ definition.manifest.id = "official.reintroduced"
+ let first = VerifiedRulePackCatalog(
+ definitions: [definition], sequence: 1, digest: String(repeating: "1", count: 64),
+ signerKeyID: "test-key", stale: false, rawEnvelope: Data("one".utf8)
+ )
+ let firstAcceptance = try db.acceptVerifiedRulePackCatalog(
+ first, checkedAt: Date(timeIntervalSince1970: 100),
+ nextEligibleAt: Date(timeIntervalSince1970: 200)
+ )
+ var configuration = RulePackConfiguration(packID: definition.id, enabled: true)
+ configuration.consentedVersion = definition.manifest.version
+ configuration.consentedContentSHA256 = definition.manifest.contentSHA256
+ try db.saveRulePackConfiguration(configuration)
+ let exception = RulePackOverride(
+ packID: definition.id, app: "com.apple.Safari", host: definition.entries[0].host
+ )
+ try db.saveRulePackOverride(exception)
+
+ let store = RulePackStore(definitions: [definition], currentAppVersion: "0.15.4")
+ store.installCommittedCatalog(first, acceptance: try db.loadRulePackCatalogBootstrap(
+ catalogID: RulePackCatalogVerifier.catalogID
+ ))
+ XCTAssertEqual(firstAcceptance.state.receipt?.sequence, 1)
+ XCTAssertEqual(store.overrides, [exception])
+ XCTAssertNotNil(store.snapshot)
+
+ let omitted = VerifiedRulePackCatalog(
+ definitions: [], sequence: 2, digest: String(repeating: "2", count: 64),
+ signerKeyID: "test-key", stale: false, rawEnvelope: Data("two".utf8)
+ )
+ let omissionAcceptance = try db.acceptVerifiedRulePackCatalog(
+ omitted, checkedAt: Date(timeIntervalSince1970: 300),
+ nextEligibleAt: Date(timeIntervalSince1970: 400)
+ )
+ store.installCommittedCatalog(omitted, acceptance: omissionAcceptance)
+ XCTAssertTrue(store.overrides.isEmpty)
+ XCTAssertNil(store.snapshot)
+
+ let reintroduced = VerifiedRulePackCatalog(
+ definitions: [definition], sequence: 3, digest: String(repeating: "3", count: 64),
+ signerKeyID: "test-key", stale: false, rawEnvelope: Data("three".utf8)
+ )
+ let reintroductionAcceptance = try db.acceptVerifiedRulePackCatalog(
+ reintroduced, checkedAt: Date(timeIntervalSince1970: 500),
+ nextEligibleAt: Date(timeIntervalSince1970: 600)
+ )
+ store.installCommittedCatalog(reintroduced, acceptance: reintroductionAcceptance)
+
+ XCTAssertEqual(store.overrides, [exception])
+ XCTAssertEqual(store.snapshot?.overrides, [exception])
+ XCTAssertTrue(store.configuration(for: definition.id).enabled)
+ }
+
+ @MainActor
+ func testCatalogInstallDoesNotOverwriteNewerLocalDisable() async throws {
+ let url = FileManager.default.temporaryDirectory
+ .appendingPathComponent("flowlight-pack-catalog-race-\(UUID().uuidString).sqlite")
+ let db = try TrafficDatabase(url: url)
+ let definition = BuiltInRulePacks.advertising
+ let first = VerifiedRulePackCatalog(
+ definitions: [definition], sequence: 1, digest: String(repeating: "1", count: 64),
+ signerKeyID: "test-key", stale: false, rawEnvelope: Data("one".utf8)
+ )
+ _ = try db.acceptVerifiedRulePackCatalog(
+ first, checkedAt: Date(timeIntervalSince1970: 100),
+ nextEligibleAt: Date(timeIntervalSince1970: 200)
+ )
+ var enabled = RulePackConfiguration(packID: definition.id, enabled: true)
+ enabled.consentedVersion = definition.manifest.version
+ enabled.consentedContentSHA256 = definition.manifest.contentSHA256
+ try db.saveRulePackConfiguration(enabled)
+
+ let store = RulePackStore(definitions: [definition], currentAppVersion: "0.15.4")
+ store.attach(db: db)
+ try await waitUntil { store.reviewReceipt(for: definition) != nil }
+ XCTAssertEqual(
+ store.installCommittedCatalog(
+ first,
+ acceptance: try db.loadRulePackCatalogBootstrap(
+ catalogID: RulePackCatalogVerifier.catalogID
+ )
+ ),
+ .installed
+ )
+ XCTAssertNotNil(store.snapshot)
+
+ let expectedRevision = store.catalogInstallRevision
+ let refreshed = VerifiedRulePackCatalog(
+ definitions: [definition], sequence: 2, digest: String(repeating: "2", count: 64),
+ signerKeyID: "test-key", stale: false, rawEnvelope: Data("two".utf8)
+ )
+ let staleAcceptance = try db.acceptVerifiedRulePackCatalog(
+ refreshed, checkedAt: Date(timeIntervalSince1970: 300),
+ nextEligibleAt: Date(timeIntervalSince1970: 400)
+ )
+
+ var disabled = enabled
+ disabled.enabled = false
+ await withCheckedContinuation { continuation in
+ store.save(disabled) { saved in
+ XCTAssertTrue(saved)
+ continuation.resume()
+ }
+ }
+ XCTAssertNil(store.snapshot)
+ XCTAssertEqual(
+ store.installCommittedCatalog(
+ refreshed,
+ acceptance: staleAcceptance,
+ expectedRevision: expectedRevision
+ ),
+ .staleRevision
+ )
+ XCTAssertFalse(store.configuration(for: definition.id).enabled)
+ XCTAssertNil(store.snapshot)
+
+ let currentRevision = store.catalogInstallRevision
+ XCTAssertEqual(
+ store.installCommittedCatalog(
+ refreshed,
+ acceptance: try db.loadRulePackCatalogBootstrap(
+ catalogID: RulePackCatalogVerifier.catalogID
+ ),
+ expectedRevision: currentRevision
+ ),
+ .installed
+ )
+ XCTAssertFalse(store.configuration(for: definition.id).enabled)
+ XCTAssertNil(store.snapshot)
+ }
+
func testEditorActivationAndCancelNeverMutateSavedConfiguration() {
let saved = RulePackConfiguration(packID: "official.advertising")
var draft = RulePackEditorDraft(saved: saved, intent: .activate)
@@ -861,7 +1078,7 @@ final class RulePackTests: XCTestCase {
let verified = try RulePackCatalogVerifier.verify(
data: data, now: 1_800_000_000, currentAppVersion: "0.15.4"
)
- let composed = try RulePackCatalogComposition.compose(verified)
+ let composed = try RulePackCatalogComposition.compose(verified, currentAppVersion: "0.15.4")
_ = try db.acceptVerifiedRulePackCatalog(
composed,
checkedAt: Date(timeIntervalSince1970: 1_800_000_000),
diff --git a/FlowlightTests/TrafficMonitorRulePackStartupTests.swift b/FlowlightTests/TrafficMonitorRulePackStartupTests.swift
index 29dbfc51..38f218ec 100644
--- a/FlowlightTests/TrafficMonitorRulePackStartupTests.swift
+++ b/FlowlightTests/TrafficMonitorRulePackStartupTests.swift
@@ -9,7 +9,7 @@ final class TrafficMonitorRulePackStartupTests: XCTestCase {
let definition = BuiltInRulePacks.advertising
let verified = try productionCatalog()
_ = try db.acceptVerifiedRulePackCatalog(
- try RulePackCatalogComposition.compose(verified), checkedAt: Date(),
+ try RulePackCatalogComposition.compose(verified, currentAppVersion: "0.15.4"), checkedAt: Date(),
nextEligibleAt: Date().addingTimeInterval(86_400)
)
var configuration = RulePackConfiguration(packID: definition.id, enabled: true)
diff --git a/Shared/RulePack.swift b/Shared/RulePack.swift
index 3d1af9a3..10d25d62 100644
--- a/Shared/RulePack.swift
+++ b/Shared/RulePack.swift
@@ -205,18 +205,21 @@ enum RulePackValidationError: Error, Equatable {
}
enum RulePackValidator {
- static let appVersion = "0.15.0"
-
+ /// App-owned callers pass the running app version. The extension omits it because snapshots have already crossed
+ /// that trust boundary; its compatibility contract is the independently versioned extension protocol.
static func validate(_ definitions: [RulePackDefinition], extensionProtocol: UInt16 = RulePackSnapshot.protocolVersion,
- currentAppVersion: String = appVersion) throws {
+ currentAppVersion: String? = nil) throws {
var packIDs = Set()
for definition in definitions {
let manifest = definition.manifest
guard manifest.format == RulePackManifest.formatVersion else {
throw RulePackValidationError.unsupportedFormat(manifest.id)
}
- guard manifest.minimumExtensionProtocol <= extensionProtocol,
- compareVersions(manifest.minimumAppVersion, currentAppVersion) != .orderedDescending else {
+ guard manifest.minimumExtensionProtocol <= extensionProtocol else {
+ throw RulePackValidationError.incompatibleProtocol(manifest.id)
+ }
+ if let currentAppVersion,
+ compareVersions(manifest.minimumAppVersion, currentAppVersion) == .orderedDescending {
throw RulePackValidationError.incompatibleProtocol(manifest.id)
}
guard !manifest.id.isEmpty,
diff --git a/scripts/build-pkg.sh b/scripts/build-pkg.sh
index 1174b61f..f524f0dc 100755
--- a/scripts/build-pkg.sh
+++ b/scripts/build-pkg.sh
@@ -31,6 +31,13 @@ SIGN=()
productbuild --package "$WORK/Flowlight-component.pkg" --identifier com.flowlight.app.installer --version "$VERSION" \
"${SIGN[@]}" "build/Flowlight-$VERSION.pkg"
+PAYLOAD_FILES="$WORK/payload-files.txt"
+pkgutil --payload-files "build/Flowlight-$VERSION.pkg" > "$PAYLOAD_FILES"
+if grep -Eq '/\._|/\.__|^\./\._' "$PAYLOAD_FILES"; then
+ echo "Package contains AppleDouble metadata files." >&2
+ exit 1
+fi
+
# A signed installer still needs notarizing, or macOS refuses to open it.
if [[ ${#SIGN[@]} -gt 0 ]]; then scripts/notarize.sh "build/Flowlight-$VERSION.pkg"; fi
echo "Built build/Flowlight-$VERSION.pkg"
From 61708b40b4ed1f90eb6d9043b68b7f1e03c61bf4 Mon Sep 17 00:00:00 2001
From: blessdyb
Date: Thu, 22 Oct 2026 00:22:45 -0700
Subject: [PATCH 09/14] Complete inspected pack enforcement
Co-Authored-By: Claude Code
---
.../Analysis/RulePackEventDelivery.swift | 135 ++++++
Flowlight/Analysis/RulePackStore.swift | 78 +++-
Flowlight/Analysis/RuleStore.swift | 226 ++++++++-
Flowlight/App/FlowlightApp.swift | 94 +++-
Flowlight/App/TrafficMonitor.swift | 127 ++++-
Flowlight/Capture/ExtensionManager.swift | 13 +-
Flowlight/Capture/ExtensionRecovery.swift | 24 +-
.../Capture/ExtensionTrafficSource.swift | 258 +++++++---
Flowlight/Capture/TrafficSource.swift | 3 +
.../Inspection/InspectionController.swift | 226 ++++++++-
Flowlight/Inspection/InspectionProxy.swift | 51 +-
Flowlight/Inspection/InspectionRecorder.swift | 3 +
.../Localization/de.lproj/Localizable.strings | 1 +
.../Localization/en.lproj/Localizable.strings | 1 +
.../Localization/es.lproj/Localizable.strings | 1 +
.../Localization/fr.lproj/Localizable.strings | 1 +
.../Localization/it.lproj/Localizable.strings | 1 +
.../Localization/ja.lproj/Localizable.strings | 1 +
.../Localization/ko.lproj/Localizable.strings | 1 +
.../pt-PT.lproj/Localizable.strings | 1 +
.../zh-Hans.lproj/Localizable.strings | 1 +
.../zh-Hant.lproj/Localizable.strings | 1 +
Flowlight/Storage/TrafficDatabase.swift | 34 +-
FlowlightExtension/BlockEnforcer.swift | 51 +-
FlowlightExtension/IPCServer.swift | 45 +-
FlowlightExtension/Info.plist | 2 +-
FlowlightTests/AppLifecycleTests.swift | 40 ++
FlowlightTests/ExtensionRebootTests.swift | 2 +
FlowlightTests/ExtensionRecoveryTests.swift | 13 +-
FlowlightTests/ExtensionSourceTests.swift | 21 +
FlowlightTests/InspectionTests.swift | 109 +++++
FlowlightTests/MockResponseTests.swift | 57 ++-
.../RulePackEventDeliveryTests.swift | 167 +++++++
FlowlightTests/RulePackTests.swift | 196 +++++++-
FlowlightTests/RuleTests.swift | 442 ++++++++++++++++++
.../TrafficMonitorRulePackStartupTests.swift | 24 +
README.md | 5 +-
Shared/IPCProtocol.swift | 8 +-
Shared/Rule.swift | 24 +
Shared/RulePack.swift | 3 +
docs/DEVELOPMENT.md | 6 +-
docs/de/docs/index.html | 6 +-
docs/de/threat-model/index.html | 21 +-
docs/docs/index.html | 10 +-
docs/es/docs/index.html | 6 +-
docs/es/threat-model/index.html | 20 +-
docs/fr/docs/index.html | 6 +-
docs/fr/threat-model/index.html | 20 +-
docs/it/docs/index.html | 6 +-
docs/it/threat-model/index.html | 20 +-
docs/ja/docs/index.html | 5 +-
docs/ja/threat-model/index.html | 20 +-
docs/ko/docs/index.html | 6 +-
docs/ko/threat-model/index.html | 19 +-
docs/llms-full.txt | 30 +-
docs/pt-PT/docs/index.html | 6 +-
docs/pt-PT/threat-model/index.html | 19 +-
docs/releases/index.html | 5 +-
docs/sitemap.xml | 42 +-
docs/threat-model/index.html | 20 +-
docs/zh-Hans/docs/index.html | 6 +-
docs/zh-Hans/threat-model/index.html | 16 +-
docs/zh-Hant/docs/index.html | 6 +-
docs/zh-Hant/threat-model/index.html | 16 +-
site/pages/de/docs.html | 4 +-
site/pages/de/threat-model.html | 19 +-
site/pages/docs.html | 8 +-
site/pages/es/docs.html | 4 +-
site/pages/es/threat-model.html | 18 +-
site/pages/fr/docs.html | 4 +-
site/pages/fr/threat-model.html | 18 +-
site/pages/it/docs.html | 4 +-
site/pages/it/threat-model.html | 18 +-
site/pages/ja/docs.html | 3 +-
site/pages/ja/threat-model.html | 18 +-
site/pages/ko/docs.html | 4 +-
site/pages/ko/threat-model.html | 17 +-
site/pages/pt-PT/docs.html | 4 +-
site/pages/pt-PT/threat-model.html | 17 +-
site/pages/releases.html | 3 +
site/pages/threat-model.html | 18 +-
site/pages/zh-Hans/docs.html | 4 +-
site/pages/zh-Hans/threat-model.html | 14 +-
site/pages/zh-Hant/docs.html | 4 +-
site/pages/zh-Hant/threat-model.html | 14 +-
85 files changed, 2603 insertions(+), 442 deletions(-)
create mode 100644 Flowlight/Analysis/RulePackEventDelivery.swift
create mode 100644 FlowlightTests/AppLifecycleTests.swift
create mode 100644 FlowlightTests/RulePackEventDeliveryTests.swift
diff --git a/Flowlight/Analysis/RulePackEventDelivery.swift b/Flowlight/Analysis/RulePackEventDelivery.swift
new file mode 100644
index 00000000..2ea2350e
--- /dev/null
+++ b/Flowlight/Analysis/RulePackEventDelivery.swift
@@ -0,0 +1,135 @@
+import Foundation
+
+/// Receives app-originated managed-pack events with the same durable watermark contract as extension events.
+/// Every refusal has its own deadline: a stalled write cannot make unrelated connections wait behind it. A retry
+/// reuses its session and sequence so an uncertain acknowledgement cannot count a refusal twice.
+final class RulePackEventDelivery: @unchecked Sendable {
+ typealias Claim = @Sendable () -> Bool
+ typealias Sink = @Sendable (
+ RulePackEventBatch,
+ @escaping Claim,
+ @escaping @Sendable (Bool) -> Void
+ ) -> Void
+
+ private final class AttemptLease: @unchecked Sendable {
+ private let lock = NSLock()
+ private var active = true
+ private var claimed = false
+
+ func claim() -> Bool {
+ lock.lock(); defer { lock.unlock() }
+ guard active else { return false }
+ claimed = true
+ return true
+ }
+
+ /// A claimed attempt has begun its serialized durable write and must be allowed to finish. An unclaimed one
+ /// can be cancelled without any later activity appearing for a connection that was allowed on timeout.
+ func cancelIfUnclaimed() -> Bool {
+ lock.lock(); defer { lock.unlock() }
+ guard active, !claimed else { return false }
+ active = false
+ return true
+ }
+
+ func invalidate() {
+ lock.lock(); defer { lock.unlock() }
+ active = false
+ }
+ }
+
+ private struct Pending: Sendable {
+ var batch: RulePackEventBatch
+ var attempts = 0
+ var generation: UInt64
+ var attemptID: UInt64 = 0
+ var lease: AttemptLease?
+ var completion: @Sendable (Bool) -> Void
+ }
+
+ private let queue = DispatchQueue(label: "flowlight.rule-pack.proxy-events")
+ private let retryDelay: TimeInterval
+ private let attemptTimeout: TimeInterval
+ private var generation: UInt64 = 0
+ private var nextDeliveryID: Int64 = 0
+ private var pending: [Int64: Pending] = [:]
+ private var attemptID: UInt64 = 0
+ private let sink: Sink
+
+ init(retryDelay: TimeInterval = 0.1,
+ attemptTimeout: TimeInterval = 5,
+ sink: @escaping Sink) {
+ self.retryDelay = retryDelay
+ self.attemptTimeout = attemptTimeout
+ self.sink = sink
+ }
+
+ func enqueue(_ event: RulePackEvent, completion: @escaping @Sendable (Bool) -> Void) {
+ queue.async { [self] in
+ nextDeliveryID += 1
+ let deliveryID = nextDeliveryID
+ // App-local retries complete against the same serialized database callback and need no permanent
+ // watermark row. The immutable batch ID still makes every retry of this refusal identical.
+ let batch = RulePackEventBatch(
+ deliverySession: UUID(),
+ sequence: 1,
+ requiresDurableWatermark: false,
+ events: [event]
+ )
+ pending[deliveryID] = Pending(batch: batch, generation: generation, completion: completion)
+ deliver(deliveryID: deliveryID)
+ }
+ }
+
+ /// Fails pre-clear authorizations open and prevents every unclaimed database block from beginning. A write that
+ /// already claimed its place on the serial database queue finishes before the clear scheduled by the completion.
+ func invalidate(completion: @escaping @Sendable () -> Void) {
+ queue.async { [self] in
+ generation &+= 1
+ let abandoned = Array(pending.values)
+ pending.removeAll()
+ abandoned.forEach {
+ $0.lease?.invalidate()
+ $0.completion(false)
+ }
+ completion()
+ }
+ }
+
+ private func deliver(deliveryID: Int64) {
+ guard var item = pending[deliveryID], item.generation == generation else { return }
+ attemptID &+= 1
+ let thisAttempt = attemptID
+ let lease = AttemptLease()
+ item.attemptID = thisAttempt
+ item.lease = lease
+ pending[deliveryID] = item
+ sink(item.batch, lease.claim) { [weak self] committed in
+ self?.queue.async {
+ self?.finished(committed: committed, deliveryID: deliveryID, attemptID: thisAttempt)
+ }
+ }
+ queue.asyncAfter(deadline: .now() + attemptTimeout) { [weak self] in
+ guard lease.cancelIfUnclaimed() else { return }
+ self?.finished(committed: false, deliveryID: deliveryID, attemptID: thisAttempt)
+ }
+ }
+
+ private func finished(committed: Bool, deliveryID: Int64, attemptID finishedAttempt: UInt64) {
+ guard var item = pending[deliveryID], item.attemptID == finishedAttempt,
+ item.generation == generation else { return }
+ item.lease?.invalidate()
+ if committed {
+ pending.removeValue(forKey: deliveryID)?.completion(true)
+ return
+ }
+ if item.attempts == 0 {
+ item.attempts = 1
+ item.lease = nil
+ pending[deliveryID] = item
+ queue.asyncAfter(deadline: .now() + retryDelay) { [weak self] in self?.deliver(deliveryID: deliveryID) }
+ } else {
+ pending.removeValue(forKey: deliveryID)?.completion(false)
+ }
+ }
+}
diff --git a/Flowlight/Analysis/RulePackStore.swift b/Flowlight/Analysis/RulePackStore.swift
index 74f70b0a..2ed270ec 100644
--- a/Flowlight/Analysis/RulePackStore.swift
+++ b/Flowlight/Analysis/RulePackStore.swift
@@ -9,6 +9,36 @@ extension RulePackOverride {
}
}
+/// The immutable pack generation the inspection proxy may carry out. A committed extension acknowledgement swaps the
+/// whole matcher in; applying, fallback, and failure clear it so the UI cannot say Blocking while the proxy uses stale policy.
+final class LiveRulePacks: @unchecked Sendable {
+ private let lock = NSLock()
+ private var digest = ""
+ private var matcher: RulePackMatcher?
+
+ func replace(snapshot: RulePackSnapshot?) {
+ let next = snapshot.flatMap { try? RulePackMatcher(snapshot: $0) }
+ lock.lock(); defer { lock.unlock() }
+ digest = next == nil ? "" : (snapshot?.digest ?? "")
+ matcher = next
+ }
+
+ func commit(snapshot: RulePackSnapshot?, activeDigest: String) {
+ guard let snapshot, snapshot.digest == activeDigest else { replace(snapshot: nil); return }
+ replace(snapshot: snapshot)
+ }
+
+ func match(host: String, bundleID: String) -> RulePackMatch? {
+ lock.lock(); defer { lock.unlock() }
+ return matcher?.matches(host: host, bundleID: bundleID).first
+ }
+
+ var activeDigest: String {
+ lock.lock(); defer { lock.unlock() }
+ return digest
+ }
+}
+
/// The app-owned half of managed rule packs: verified definitions, user activation/scope, exceptions, and the
/// complete validated snapshot handed to the Network Extension.
@MainActor
@@ -45,6 +75,8 @@ final class RulePackStore: ObservableObject {
private var catalogReceipt: RulePackCatalogReceipt?
private var committedSnapshot: RulePackSnapshot?
private var revision = 0
+ private var activityRevision = 0
+ private var clearingActivity = false
private let currentAppVersion: String
init(definitions: [RulePackDefinition] = BuiltInRulePacks.all,
@@ -289,37 +321,65 @@ final class RulePackStore: ObservableObject {
status = .applying
}
- func record(_ batch: RulePackEventBatch, completion: @escaping (Bool) -> Void) {
+ func record(_ batch: RulePackEventBatch,
+ claim: @escaping @Sendable () -> Bool = { true },
+ acknowledgeDuringClear: Bool = true,
+ completion: @escaping @Sendable (Bool) -> Void) {
+ guard !clearingActivity else { completion(acknowledgeDuringClear); return }
guard let db else { completion(false); return }
+ let expectedActivityRevision = activityRevision
db.queue.async { [weak self, weak db] in
guard let db else { completion(false); return }
+ guard claim() else { completion(false); return }
do {
_ = try db.recordRulePackEventBatch(batch)
+ } catch {
+ appLog.error("rule pack batch failed: \(String(describing: error), privacy: .public)")
+ completion(false)
+ return
+ }
+ // Authorization waits only for the durable transaction. Feed loading and MainActor publication are
+ // not part of the refusal critical path and cannot hold a connection after SQLite has committed it.
+ completion(true)
+ do {
let events = try db.rulePackEvents(limit: 300)
let deliveryStats = try db.rulePackDeliveryStats()
Task { @MainActor in
- self?.revision += 1
- self?.events = events
- self?.deliveryStats = deliveryStats
- self?.version += 1
- completion(true)
+ guard let self,
+ self.activityRevision == expectedActivityRevision else { return }
+ self.revision += 1
+ self.events = events
+ self.deliveryStats = deliveryStats
+ self.version += 1
}
} catch {
- appLog.error("rule pack batch failed: \(String(describing: error), privacy: .public)")
- Task { @MainActor in completion(false) }
+ appLog.error("rule pack activity refresh failed: \(String(describing: error), privacy: .public)")
}
}
}
func events(for packID: String) -> [RulePackEventRecord] { events.filter { $0.packID == packID } }
- func clearActivity() {
+ func beginActivityClear() {
+ clearingActivity = true
revision += 1
+ activityRevision += 1
events = []
deliveryStats = RulePackDeliveryStats(droppedHits: 0, droppedKeys: 0)
version += 1
}
+ func finishActivityClear(succeeded: Bool) {
+ clearingActivity = false
+ if !succeeded { reload() }
+ }
+
+ /// Clears only the published model after the database rows have already been deleted by the caller.
+ func clearPublishedActivityAfterDurableDeletion() {
+ beginActivityClear()
+ finishActivityClear(succeeded: true)
+ }
+
private func applyLoaded(configurations saved: [RulePackConfiguration],
overrides loadedOverrides: [RulePackOverride],
events loadedEvents: [RulePackEventRecord],
diff --git a/Flowlight/Analysis/RuleStore.swift b/Flowlight/Analysis/RuleStore.swift
index d6639819..c25bb545 100644
--- a/Flowlight/Analysis/RuleStore.swift
+++ b/Flowlight/Analysis/RuleStore.swift
@@ -22,6 +22,12 @@ final class RuleStore: ObservableObject {
var onChange: (RuleSet) -> Void = { _ in }
private weak var db: TrafficDatabase?
+ private var pendingHitResets: Set = []
+ private var pendingRuleInvalidations: Set = []
+ /// The first whole-list generation that describes each rule's current definition. Delayed decisions from an
+ /// older definition are ignored, while an unrelated rule edit does not erase a valid spend.
+ private var ruleInvalidatedAt: [UUID: UInt64] = [:]
+ private var publicationGeneration: UInt64 = 0
private var pauseTimer: Timer?
/// The same rules, readable from the proxy's queue. The store itself is main-actor — it is a published model —
/// but the proxy asks which rules apply while framing a request, so it needs an answer without a hop.
@@ -69,12 +75,14 @@ final class RuleStore: ObservableObject {
} else {
rules.append(rule)
}
+ pendingRuleInvalidations.insert(rule.id)
db?.async { try $0.saveRule(rule) }
publish()
}
func delete(_ rule: Rule) {
rules.removeAll { $0.id == rule.id }
+ pendingRuleInvalidations.insert(rule.id)
db?.async { try $0.deleteRule(rule.id) }
publish()
}
@@ -90,6 +98,7 @@ final class RuleStore: ObservableObject {
guard var found = rules.first(where: { $0.id == rule.id }) else { return }
found.hits = 0
found.lastHit = nil
+ pendingHitResets.insert(found.id)
save(found)
}
@@ -119,22 +128,81 @@ final class RuleStore: ObservableObject {
/// Decisions from the filter: recorded, counted, and shown. Both refusals and relaxations arrive here.
func record(_ incoming: [RuleEvent]) {
guard !incoming.isEmpty else { return }
+ var accepted: [RuleEvent] = []
for event in incoming {
+ if let eventSession = event.ruleSession,
+ let eventGeneration = event.ruleGeneration,
+ !acceptsDecision(
+ ruleID: event.ruleID,
+ session: eventSession,
+ generation: eventGeneration
+ ) {
+ continue
+ }
+ accepted.append(event)
guard let index = rules.firstIndex(where: { $0.id == event.ruleID }) else { continue }
- rules[index].hits += 1
+ rules[index].hits = rules[index].maxHits > 0
+ ? min(rules[index].maxHits, rules[index].hits + 1)
+ : rules[index].hits + 1
rules[index].lastHit = Date(timeIntervalSince1970: TimeInterval(event.at))
let rule = rules[index]
db?.async { try $0.saveRule(rule) }
}
+ guard !accepted.isEmpty else { return }
db?.async { [weak self] db in
- try db.recordRuleEvents(incoming)
+ try db.recordRuleEvents(accepted)
let events = (try? db.ruleEvents(limit: 300)) ?? []
Task { @MainActor in
self?.events = events
self?.version += 1
}
}
- publish()
+ publish(advancingGeneration: false)
+ }
+
+ /// Clears the published feed after its durable rows were deleted by the caller.
+ func clearPublishedEventsAfterDurableDeletion() {
+ events = []
+ version += 1
+ }
+
+ /// Conservatively retires a bounded claim whose XPC reply was lost. The extension may already have accepted it;
+ /// saving the spent count before authorization completes prevents reconnect or relaunch from reusing it.
+ func retireBoundedClaim(
+ _ claim: RuleClaim,
+ completion: @escaping @Sendable (Bool) -> Void
+ ) {
+ guard acceptsDecision(
+ ruleID: claim.ruleID,
+ session: claim.session,
+ generation: claim.generation
+ ),
+ let index = rules.firstIndex(where: { $0.id == claim.ruleID }),
+ rules[index].maxHits > 0 else {
+ completion(false)
+ return
+ }
+ rules[index].hits = max(rules[index].hits, rules[index].maxHits)
+ rules[index].lastHit = Date()
+ let rule = rules[index]
+ live.markBoundedRuleSpent(claim)
+ publish(advancingGeneration: false)
+ guard let db else {
+ completion(false)
+ return
+ }
+ db.queue.async { [weak db] in
+ guard let db else {
+ completion(false)
+ return
+ }
+ do {
+ try db.saveRule(rule)
+ completion(true)
+ } catch {
+ completion(false)
+ }
+ }
}
/// The feed for one rule, for its own page.
@@ -167,15 +235,40 @@ final class RuleStore: ObservableObject {
// MARK: Telling everyone else
- private func publish() {
+ private func acceptsDecision(
+ ruleID: UUID,
+ session: String,
+ generation: UInt64
+ ) -> Bool {
+ session == Self.session &&
+ generation <= publicationGeneration &&
+ generation >= (ruleInvalidatedAt[ruleID] ?? 0)
+ }
+
+ private func publish(advancingGeneration: Bool = true) {
version += 1
- live.replace(rules, pausedUntil: pausedUntil)
+ if advancingGeneration {
+ publicationGeneration &+= 1
+ for ruleID in pendingRuleInvalidations {
+ ruleInvalidatedAt[ruleID] = publicationGeneration
+ }
+ }
+ live.replace(
+ rules,
+ pausedUntil: pausedUntil,
+ generation: publicationGeneration,
+ resetHitCounts: pendingHitResets,
+ ruleInvalidatedAt: ruleInvalidatedAt
+ )
onChange(ruleSet)
+ pendingHitResets.removeAll()
+ pendingRuleInvalidations.removeAll()
}
var ruleSet: RuleSet {
RuleSet(rules: rules.filter { $0.enabled && $0.isComplete && $0.engine == .flow },
- pausedUntil: pausedUntil, session: Self.session)
+ generation: publicationGeneration, pausedUntil: pausedUntil, session: Self.session,
+ resetHitCounts: pendingHitResets, ruleInvalidatedAt: ruleInvalidatedAt)
}
/// The rules the inspection proxy carries out: the ones that name a path or a method, which no filter can see.
@@ -185,15 +278,132 @@ final class RuleStore: ObservableObject {
/// The rule list as anything off the main actor sees it. Held behind a lock rather than an actor because the
/// proxy asks while it is framing a request, and a request that has to wait for an actor hop is a request that
/// arrives late.
+struct LiveFlowRuleDecision: Sendable {
+ var paused: Bool
+ var decision: RuleDecision
+ var externalClaim: RuleClaim?
+}
+
+final class LiveRuleClaimAuthority: @unchecked Sendable {
+ private let lock = NSLock()
+ private weak var source: ExtensionTrafficSource?
+
+ func replace(_ source: ExtensionTrafficSource?) {
+ lock.lock(); defer { lock.unlock() }
+ self.source = source
+ }
+
+ func claim(_ claim: RuleClaim, completion: @escaping @Sendable (Bool?) -> Void) {
+ lock.lock()
+ let source = source
+ lock.unlock()
+ source?.claimRule(claim, completion: completion) ?? completion(nil)
+ }
+}
+
final class LiveRules: @unchecked Sendable {
private let lock = NSLock()
private var rules: [Rule] = []
private var pausedUntil: Date?
+ private var generation: UInt64 = 0
+ private var ruleInvalidatedAt: [UUID: UInt64] = [:]
+ private var usesExternalClaimAuthority = false
+
+ /// Atomically hands finite-rule counting to the extension and returns the exact whole-list publication it should
+ /// install. Any local claim completed before this lock is reflected in the transferred hit count; every decision
+ /// after it carries a generation-bound external claim instead of spending independently.
+ func beginExternalClaimAuthority(with set: RuleSet) -> RuleSet {
+ lock.lock(); defer { lock.unlock() }
+ usesExternalClaimAuthority = true
+ var synchronized = set
+ synchronized.rules = set.rules.map { published in
+ guard let runtime = rules.first(where: { $0.id == published.id }),
+ runtime.hits > published.hits else { return published }
+ var merged = published
+ merged.hits = runtime.hits
+ merged.lastHit = runtime.lastHit
+ return merged
+ }
+ return synchronized
+ }
- func replace(_ rules: [Rule], pausedUntil: Date?) {
+ func replace(
+ _ rules: [Rule],
+ pausedUntil: Date?,
+ generation: UInt64 = 0,
+ resetHitCounts: Set = [],
+ ruleInvalidatedAt: [UUID: UInt64] = [:]
+ ) {
lock.lock(); defer { lock.unlock() }
- self.rules = rules
+ self.rules = rules.map { published in
+ guard !resetHitCounts.contains(published.id),
+ let runtime = self.rules.first(where: { $0.id == published.id }),
+ runtime.hits > published.hits else {
+ return published
+ }
+ var merged = published
+ merged.hits = runtime.hits
+ merged.lastHit = runtime.lastHit
+ return merged
+ }
self.pausedUntil = pausedUntil
+ self.generation = generation
+ self.ruleInvalidatedAt.merge(ruleInvalidatedAt, uniquingKeysWith: max)
+ for ruleID in resetHitCounts {
+ self.ruleInvalidatedAt[ruleID] = generation
+ }
+ }
+
+ /// The flow-level answer for an already named proxied connection. Pause and rules are read under one lock so a
+ /// connection cannot combine the pause from one publication with the rule list from another. Bounded rules can
+ /// be previewed without spending them while the extension atomically claims the decision for both data paths.
+ func flowDecision(
+ _ facts: FlowFacts,
+ now: Date = Date()
+ ) -> LiveFlowRuleDecision {
+ lock.lock(); defer { lock.unlock() }
+ let consumeBounded = !usesExternalClaimAuthority
+ let paused = pausedUntil.map { now < $0 } ?? false
+ let decision = RuleBook.decide(facts, rules: rules, now: now, session: RuleStore.session,
+ pausedUntil: pausedUntil)
+ if let rule = decision.rule,
+ (rule.maxHits == 0 || consumeBounded),
+ let index = rules.firstIndex(where: { $0.id == rule.id }) {
+ rules[index].hits += 1
+ rules[index].lastHit = now
+ }
+ return LiveFlowRuleDecision(
+ paused: paused,
+ decision: decision,
+ externalClaim: decision.rule.flatMap { rule in
+ rule.maxHits > 0 && !consumeBounded
+ ? RuleClaim(ruleID: rule.id, session: RuleStore.session, generation: generation)
+ : nil
+ }
+ )
+ }
+
+ private func acceptsDecision(_ claim: RuleClaim) -> Bool {
+ claim.session == RuleStore.session &&
+ claim.generation <= generation &&
+ claim.generation >= (ruleInvalidatedAt[claim.ruleID] ?? 0)
+ }
+
+ func markBoundedRuleClaimed(_ claim: RuleClaim, at now: Date = Date()) {
+ lock.lock(); defer { lock.unlock() }
+ guard acceptsDecision(claim),
+ let index = rules.firstIndex(where: { $0.id == claim.ruleID }),
+ rules[index].maxHits > 0 else { return }
+ rules[index].hits = min(rules[index].maxHits, rules[index].hits + 1)
+ rules[index].lastHit = now
+ }
+
+ func markBoundedRuleSpent(_ claim: RuleClaim) {
+ lock.lock(); defer { lock.unlock() }
+ guard acceptsDecision(claim),
+ let index = rules.firstIndex(where: { $0.id == claim.ruleID }),
+ rules[index].maxHits > 0 else { return }
+ rules[index].hits = max(rules[index].hits, rules[index].maxHits)
}
/// The rules that would answer a request right now. Timing is judged here, at the moment the request arrives,
diff --git a/Flowlight/App/FlowlightApp.swift b/Flowlight/App/FlowlightApp.swift
index 4d146b04..ea8cec29 100644
--- a/Flowlight/App/FlowlightApp.swift
+++ b/Flowlight/App/FlowlightApp.swift
@@ -3,7 +3,39 @@ import SwiftUI
/// Handles the one thing SwiftUI's scene lifecycle doesn't give cleanly: a hook that runs as the app quits, used to
/// strip the proxy routing Flowlight persisted into agents' settings files so a clean quit never leaves one pointing
/// at a proxy that is about to stop. A crash skips this; `ProxyJanitor` covers that.
+@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate {
+ private static var didFinishLaunching = false
+ private static var didStart = false
+ private static var startup: (() -> Void)?
+
+ static func installStartup(_ action: @escaping () -> Void) {
+ guard !didStart else { return }
+ startup = action
+ runStartupIfReady()
+ }
+
+ func applicationDidFinishLaunching(_ notification: Notification) {
+ Self.didFinishLaunching = true
+ Self.runStartupIfReady()
+ }
+
+ private static func runStartupIfReady() {
+ guard didFinishLaunching, !didStart, let startup else { return }
+ didStart = true
+ self.startup = nil
+ startup()
+ }
+
+ #if DEBUG
+ static func resetStartupForTests(didFinishLaunching: Bool = false) {
+ guard FlowlightApp.runningTests else { return }
+ self.didFinishLaunching = didFinishLaunching
+ didStart = false
+ startup = nil
+ }
+ #endif
+
func applicationWillTerminate(_ notification: Notification) {
// Not in tests (they share this process), and not in a demo or screenshot run: that is a throwaway instance
// with its own database, and it must never strip the enforcement the real running app owns.
@@ -41,11 +73,38 @@ struct FlowlightApp: App {
}
}
- @StateObject private var monitor = TrafficMonitor()
- @StateObject private var extensionManager = ExtensionManager()
- @StateObject private var nav = AppNavigation()
- @StateObject private var updater = UpdateChecker()
- @StateObject private var focus = FocusStore()
+ @StateObject private var monitor: TrafficMonitor
+ @StateObject private var extensionManager: ExtensionManager
+ @StateObject private var nav: AppNavigation
+ @StateObject private var updater: UpdateChecker
+ @StateObject private var focus: FocusStore
+
+ init() {
+ let monitor = TrafficMonitor()
+ let extensionManager = ExtensionManager()
+ let nav = AppNavigation()
+ let updater = UpdateChecker()
+ let focus = FocusStore()
+ _monitor = StateObject(wrappedValue: monitor)
+ _extensionManager = StateObject(wrappedValue: extensionManager)
+ _nav = StateObject(wrappedValue: nav)
+ _updater = StateObject(wrappedValue: updater)
+ _focus = StateObject(wrappedValue: focus)
+
+ // SwiftUI may restore a background-only launch with no window and without rendering the menu-bar label yet.
+ // Core services belong to the application lifecycle, not to either presentation path.
+ guard !Self.runningTests else { return }
+ AppDelegate.installStartup {
+ monitor.start()
+ monitor.applyFocus(focus.scope)
+ extensionManager.refresh()
+ extensionManager.matchExtensionToApp { check in
+ guard check.repairing else { return }
+ Task { @MainActor in monitor.extensionVersionRepairing(check) }
+ }
+ updater.start()
+ }
+ }
var body: some Scene {
WindowGroup("Flowlight", id: "main") {
@@ -56,21 +115,6 @@ struct FlowlightApp: App {
.environmentObject(updater)
.environmentObject(focus)
.frame(minWidth: 1000, minHeight: 660)
- .task {
- // The unit tests are hosted in this app, so every test run launches it for real: capture
- // starts, nettop is spawned every second, XPC connects, the update check fires. None of that
- // belongs in a test run, and it is where CI intermittently hangs.
- guard !FlowlightApp.runningTests else { return }
- monitor.start()
- monitor.applyFocus(focus.scope)
- extensionManager.refresh()
- extensionManager.matchExtensionToApp { check in
- guard check.repairing else { return }
- Task { @MainActor in monitor.extensionVersionRepairing(check) }
- }
- updater.start()
- }
- .onChange(of: focus.scope) { _, scope in monitor.applyFocus(scope) }
.onChange(of: backgroundOnly, initial: true) { _, on in Self.applyActivationPolicy(backgroundOnly: on) }
.onAppear { Self.windowsChanged() }
.onDisappear { Self.windowsChanged() }
@@ -134,7 +178,11 @@ struct FlowlightApp: App {
.environmentObject(updater)
.environmentObject(focus)
} label: {
- MenuBarLabel().environmentObject(monitor).environmentObject(updater)
+ MenuBarLabel()
+ .environmentObject(monitor)
+ .environmentObject(extensionManager)
+ .environmentObject(updater)
+ .environmentObject(focus)
}
Settings {
@@ -146,11 +194,15 @@ struct FlowlightApp: App {
struct MenuBarLabel: View {
@EnvironmentObject var monitor: TrafficMonitor
@EnvironmentObject var updater: UpdateChecker
+ @EnvironmentObject var focus: FocusStore
@Environment(\.openWindow) private var openWindow
+ @AppStorage(AnomalySettings.Keys.backgroundOnly) private var backgroundOnly = false
@AppStorage(AnomalySettings.Keys.menuBarRates) private var showRates = true
var body: some View {
label
+ .onChange(of: focus.scope) { _, scope in monitor.applyFocus(scope) }
+ .onChange(of: backgroundOnly, initial: true) { _, _ in FlowlightApp.windowsChanged() }
// The menu bar item always exists, so a found update opens its window even with the main window closed.
.onChange(of: updater.showWindow) { _, show in
if show { openWindow(id: "update"); updater.showWindow = false }
diff --git a/Flowlight/App/TrafficMonitor.swift b/Flowlight/App/TrafficMonitor.swift
index 925e86e7..0f56b3b5 100644
--- a/Flowlight/App/TrafficMonitor.swift
+++ b/Flowlight/App/TrafficMonitor.swift
@@ -56,6 +56,12 @@ final class TrafficMonitor: ObservableObject {
let rules = RuleStore()
/// Official managed destination lists, kept separate from human-authored rules and disabled by default.
let rulePacks: RulePackStore
+ /// The exact extension-committed generation, readable from the inspection proxy's queue.
+ private let liveRulePacks = LiveRulePacks()
+ /// The active extension is the one atomic authority for bounded rules across direct and proxied flows.
+ private let liveRuleClaims = LiveRuleClaimAuthority()
+ /// App-local pack refusals use their own ordered, retryable delivery session.
+ private var proxyRulePackEvents: RulePackEventDelivery!
/// Signed official catalog bootstrap, cache re-verification, and bounded refresh state.
private(set) var rulePackCatalog: RulePackCatalogUpdater!
/// Which tools each agent may use, which is a different question from which hosts it may reach.
@@ -127,6 +133,16 @@ final class TrafficMonitor: ObservableObject {
}
readDB = (try? TrafficDatabase(url: db.url, readOnly: true)) ?? db
engine = AnomalyEngine(db: db, activity: activity)
+ proxyRulePackEvents = RulePackEventDelivery { [weak rulePacks] batch, claim, completion in
+ Task { @MainActor in
+ rulePacks?.record(
+ batch,
+ claim: claim,
+ acknowledgeDuringClear: false,
+ completion: completion
+ ) ?? completion(false)
+ }
+ }
rulePackCatalog = RulePackCatalogUpdater(
db: db,
store: rulePacks,
@@ -160,6 +176,12 @@ final class TrafficMonitor: ObservableObject {
func start() {
guard !started else { return }
started = true
+ // A signed build may have an older extension process still enforcing its disconnect grace or holding a
+ // buffered finite-rule decision. Reserve the extension as the only counter from process startup, even when
+ // this run begins in sampler mode; unavailable claims fail open rather than reusing a bounded allowance.
+ if ExtensionManager.isEntitled {
+ _ = rules.live.beginExternalClaimAuthority(with: rules.ruleSet)
+ }
if DemoData.isEnabled {
IPOwnerLookup.shared.isEnabled = { false }
IPOwnerLookup.shared.preload(DemoData.owners())
@@ -177,16 +199,42 @@ final class TrafficMonitor: ObservableObject {
}
inspection.attach(db: db)
Task { await localRisk.recover() }
- rules.onChange = { [weak self] set in self?.source?.setRules(set) }
+ rules.onChange = { [weak self] set in
+ guard let self else { return }
+ if self.source is ExtensionTrafficSource {
+ self.source?.setRules(self.rules.live.beginExternalClaimAuthority(with: set))
+ } else {
+ self.source?.setRules(set)
+ }
+ }
rules.attach(db: db)
rulePacks.onChange = { [weak self] snapshot in
guard let self else { return }
+ self.liveRulePacks.replace(snapshot: nil)
if self.source is ExtensionTrafficSource { self.rulePacks.markApplying() }
else { self.rulePacks.updateStatus(self.rulePacks.hasEnabledPacks ? .watchingOnly : .off) }
self.source?.setRulePacks(snapshot)
}
- // A rule that names a path is carried out by the proxy, which reads its list fresh on every connection.
+ // The proxy reads one lock-backed publication for rules and one for the extension-committed pack generation.
inspection.requestRules = { [live = rules.live] in live.requestRules() }
+ inspection.flowRuleDecision = { [live = rules.live] facts in
+ live.flowDecision(facts)
+ }
+ inspection.claimFlowRule = { [liveRuleClaims] claim, completion in
+ liveRuleClaims.claim(claim, completion: completion)
+ }
+ inspection.flowRuleClaimed = { [live = rules.live] claim in live.markBoundedRuleClaimed(claim) }
+ inspection.flowRuleSpent = { [weak rules] claim, completion in
+ Task { @MainActor in
+ rules?.retireBoundedClaim(claim, completion: completion) ?? completion(false)
+ }
+ }
+ inspection.rulePackMatch = { [liveRulePacks] host, bundleID in
+ liveRulePacks.match(host: host, bundleID: bundleID)
+ }
+ inspection.onRulePackRefusal = { [weak proxyRulePackEvents] event, completion in
+ proxyRulePackEvents?.enqueue(event, completion: completion) ?? completion(false)
+ }
inspection.onRuleRefusal = { [weak self] event in
Task { @MainActor in self?.rules.record([event]) }
}
@@ -379,6 +427,7 @@ final class TrafficMonitor: ObservableObject {
private func startSource() {
guard rulePackCatalogReady else { return }
+ liveRulePacks.replace(snapshot: nil)
source?.stop()
let newSource: TrafficSource
// A build without the entitlement can never reach the extension; capture with the sampler rather than
@@ -416,8 +465,11 @@ final class TrafficMonitor: ObservableObject {
extensionSource.onRulePackEvents = { [weak self] batch, completion in
self?.recordRulePackEvents(batch, completion: completion) ?? completion(false)
}
- extensionSource.onRulePackStatus = { [weak self] result in
- Task { @MainActor in self?.updateRulePackStatus(result) }
+ extensionSource.onRulePackStatus = { [weak self, weak extensionSource] result in
+ Task { @MainActor in
+ guard let self, let extensionSource, self.source === extensionSource else { return }
+ self.updateRulePackStatus(result)
+ }
}
extensionSource.onRepairRequested = { [weak self] in
Task { @MainActor in self?.repairExtensionVersion() }
@@ -427,6 +479,16 @@ final class TrafficMonitor: ObservableObject {
}
}
source = newSource
+ let extensionSource = newSource as? ExtensionTrafficSource
+ liveRuleClaims.replace(extensionSource)
+ let sourceRuleSet: RuleSet
+ if extensionSource != nil {
+ sourceRuleSet = rules.live.beginExternalClaimAuthority(with: rules.ruleSet)
+ } else {
+ // External finite-rule ownership is session-sticky. The extension deliberately enforces for a short
+ // disconnect grace and may still hold an undelivered spend; resuming a local counter would permit reuse.
+ sourceRuleSet = rules.ruleSet
+ }
status = fallbackNote ?? L("Starting %@…", newSource.displayName)
if started { updatePacketCapture() }
newSource.start(sink: { [weak self] batches in
@@ -435,7 +497,7 @@ final class TrafficMonitor: ObservableObject {
Task { @MainActor in self?.status = fallbackNote ?? message }
})
pushEnforcement()
- newSource.setRules(rules.ruleSet)
+ newSource.setRules(sourceRuleSet)
newSource.setRulePacks(rulePacks.snapshot)
if rulePacks.hasEnabledPacks, !(newSource is ExtensionTrafficSource) {
rulePacks.updateStatus(.watchingOnly)
@@ -445,24 +507,41 @@ final class TrafficMonitor: ObservableObject {
private func updateRulePackStatus(_ result: RulePackApplyResult) {
switch result.status {
case .accepted:
+ liveRulePacks.replace(snapshot: nil)
rulePacks.updateStatus(.applying)
case .committed:
- rulePacks.updateStatus(.enforcing)
+ let desired = rulePacks.snapshot
+ liveRulePacks.commit(snapshot: desired, activeDigest: result.activeDigest)
+ if desired?.digest == result.activeDigest {
+ rulePacks.updateStatus(.enforcing)
+ } else {
+ rulePacks.updateStatus(.failed("The filter acknowledged a different rule pack snapshot."))
+ }
case .cleared:
+ liveRulePacks.replace(snapshot: nil)
rulePacks.updateStatus(.off)
case .incompatible:
+ liveRulePacks.replace(snapshot: nil)
rulePacks.updateStatus(.incompatible(result.detail))
case .invalid, .stale:
+ liveRulePacks.replace(snapshot: nil)
rulePacks.updateStatus(.failed(result.detail))
}
}
- nonisolated private func recordRulePackEvents(_ batch: RulePackEventBatch,
- completion: @escaping (Bool) -> Void) {
- Task { @MainActor in
+ nonisolated private func recordRulePackEvents(
+ _ batch: RulePackEventBatch,
+ completion: @escaping @Sendable (Bool) -> Void
+ ) {
+ Task { @MainActor [weak self] in
+ guard let self else {
+ completion(false)
+ return
+ }
self.rulePacks.record(batch) { [weak self] committed in
- if committed { self?.dataVersion += 1 }
completion(committed)
+ guard committed else { return }
+ Task { @MainActor [weak self] in self?.dataVersion += 1 }
}
}
}
@@ -742,16 +821,24 @@ final class TrafficMonitor: ObservableObject {
}
func clearAllData() {
- db.async { [weak self] db in
- try db.clearAll()
- Task { @MainActor in
- self?.sessionPerApp.removeAll()
- self?.perSecond.removeAll()
- self?.sessionIn = 0
- self?.sessionOut = 0
- self?.rulePacks.clearActivity()
- self?.dataVersion += 1
- self?.refreshAlertCount()
+ rulePacks.beginActivityClear()
+ proxyRulePackEvents.invalidate { [weak self, db] in
+ Task { @MainActor [weak self, db] in
+ guard let self else { return }
+ do {
+ try await db.asyncResult { try $0.clearAll() }
+ self.sessionPerApp.removeAll()
+ self.perSecond.removeAll()
+ self.sessionIn = 0
+ self.sessionOut = 0
+ self.rulePacks.finishActivityClear(succeeded: true)
+ self.rules.clearPublishedEventsAfterDurableDeletion()
+ self.dataVersion += 1
+ self.refreshAlertCount()
+ } catch {
+ self.rulePacks.finishActivityClear(succeeded: false)
+ self.lastError = L("Couldn't delete the captured data: %@", error.localizedDescription)
+ }
}
}
}
diff --git a/Flowlight/Capture/ExtensionManager.swift b/Flowlight/Capture/ExtensionManager.swift
index 45d48bc8..a17ceb84 100644
--- a/Flowlight/Capture/ExtensionManager.swift
+++ b/Flowlight/Capture/ExtensionManager.swift
@@ -94,7 +94,8 @@ final class ExtensionManager: NSObject, ObservableObject {
guard let bundle = Bundle(url: url),
let version = bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String,
!version.isEmpty else { return appVersion }
- return version
+ let build = bundle.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? ""
+ return build.isEmpty ? version : "\(version)/\(build)"
}
/// Whether this build carries the content-filter entitlement (false for ad-hoc/local builds).
@@ -245,6 +246,12 @@ final class ExtensionManager: NSObject, ObservableObject {
}
extension ExtensionManager: OSSystemExtensionRequestDelegate {
+ nonisolated private static func versionIdentity(_ properties: OSSystemExtensionProperties) -> String {
+ let version = properties.bundleShortVersion
+ let build = properties.bundleVersion
+ return build.isEmpty ? version : "\(version)/\(build)"
+ }
+
nonisolated func request(_ request: OSSystemExtensionRequest, actionForReplacingExtension existing: OSSystemExtensionProperties,
withExtension ext: OSSystemExtensionProperties) -> OSSystemExtensionRequest.ReplacementAction {
.replace
@@ -254,10 +261,10 @@ extension ExtensionManager: OSSystemExtensionRequestDelegate {
nonisolated func request(_ request: OSSystemExtensionRequest, foundProperties properties: [OSSystemExtensionProperties]) {
// Only the enabled copies count. A disabled one holds nothing and replacing it would quietly switch the
// filter back on for someone who turned it off on purpose.
- let installed = properties.filter { $0.isEnabled && !$0.isUninstalling }.map(\.bundleShortVersion)
+ let installed = properties.filter { $0.isEnabled && !$0.isUninstalling }.map(Self.versionIdentity)
// Copies macOS has terminated and is holding until a restart. They still occupy the one content-filter
// slot, so the enabled build is installed without being the one running.
- let awaitingReboot = properties.filter(\.isUninstalling).map(\.bundleShortVersion)
+ let awaitingReboot = properties.filter(\.isUninstalling).map(Self.versionIdentity)
Task { @MainActor in
let expected = Self.expectedExtensionVersion
self.versionCheckResult = VersionCheck(installed: installed, expectedVersion: expected,
diff --git a/Flowlight/Capture/ExtensionRecovery.swift b/Flowlight/Capture/ExtensionRecovery.swift
index cad58d23..e7c69c9d 100644
--- a/Flowlight/Capture/ExtensionRecovery.swift
+++ b/Flowlight/Capture/ExtensionRecovery.swift
@@ -119,19 +119,25 @@ enum ExtensionVersion {
}
/// Dotted numeric comparison, so "0.5" and "0.5.0" are one version and "0.10.0" comes after "0.9.0" — which
- /// plain string comparison gets backwards. A component that isn't a number counts as zero, and anything
- /// trailing it is ignored, which is what makes the "0.5.0/8" form `systemextensionsctl` prints work here too.
+ /// plain string comparison gets backwards. `systemextensionsctl` appends the bundle build after `/`; compare it
+ /// when both sides provide one, while retaining compatibility with older callers that know only the short version.
static func compare(_ lhs: String, _ rhs: String) -> ComparisonResult {
- let left = components(lhs), right = components(rhs)
- for index in 0.. [Int] {
- version.split(separator: ".").map { Int($0.prefix(while: \.isNumber)) ?? 0 }
+ private static func identity(_ value: String) -> (version: [Int], build: Int?) {
+ let parts = value.split(separator: "/", maxSplits: 1, omittingEmptySubsequences: false)
+ let version = parts[0].split(separator: ".").map { Int($0.prefix(while: \.isNumber)) ?? 0 }
+ let build = parts.count == 2 ? Int(parts[1].prefix(while: \.isNumber)) : nil
+ return (version, build)
}
}
diff --git a/Flowlight/Capture/ExtensionTrafficSource.swift b/Flowlight/Capture/ExtensionTrafficSource.swift
index 7b9fad4f..97b37639 100644
--- a/Flowlight/Capture/ExtensionTrafficSource.swift
+++ b/Flowlight/Capture/ExtensionTrafficSource.swift
@@ -1,5 +1,33 @@
import Foundation
+/// Serializes access to the extension's one mutable transfer buffer. Repeating the active epoch is redundant; a newer
+/// epoch is coalesced and starts only after the active attempt reaches a terminal callback.
+struct RulePackPushGate {
+ private(set) var activeEpoch: UInt64?
+ private var hasPendingNewerEpoch = false
+
+ mutating func begin(epoch: UInt64) -> Bool {
+ guard let activeEpoch else {
+ self.activeEpoch = epoch
+ return true
+ }
+ if activeEpoch != epoch { hasPendingNewerEpoch = true }
+ return false
+ }
+
+ mutating func finish(epoch: UInt64) -> Bool? {
+ guard activeEpoch == epoch else { return nil }
+ activeEpoch = nil
+ defer { hasPendingNewerEpoch = false }
+ return hasPendingNewerEpoch
+ }
+
+ mutating func reset() {
+ activeEpoch = nil
+ hasPendingNewerEpoch = false
+ }
+}
+
/// Receives per-second batches from the system extension over XPC.
final class ExtensionTrafficSource: NSObject, TrafficSource, FlowlightAppXPC, @unchecked Sendable {
var displayName: String { L("Network Extension") }
@@ -28,11 +56,13 @@ final class ExtensionTrafficSource: NSObject, TrafficSource, FlowlightAppXPC, @u
/// Connections a rule decided, refusals and relaxations alike.
var onRuleDecisions: (([RuleEvent]) -> Void)?
/// Aggregated managed-pack decisions. These never enter the ordinary block-alert path.
- var onRulePackEvents: ((RulePackEventBatch, @escaping (Bool) -> Void) -> Void)?
+ var onRulePackEvents: ((RulePackEventBatch, @escaping @Sendable (Bool) -> Void) -> Void)?
var onRulePackStatus: ((RulePackApplyResult) -> Void)?
private var rulePackSnapshot: RulePackSnapshot?
/// Invalidates every callback from a superseded desired snapshot or XPC connection.
private var rulePackPushEpoch: UInt64 = 0
+ /// The extension owns one mutable transfer buffer, so overlapping handshakes must never race to reset it.
+ private var rulePackPushGate = RulePackPushGate()
/// The escalation ladder for a connection that keeps failing. Only touched on the main thread, like
/// `connection`, because a decision made from two queues at once is no decision at all.
private var recovery = ExtensionRecovery()
@@ -79,6 +109,7 @@ final class ExtensionTrafficSource: NSObject, TrafficSource, FlowlightAppXPC, @u
connection = nil
superseded?.invalidate()
rulePackPushEpoch &+= 1
+ rulePackPushGate.reset()
let connection = NSXPCConnection(machServiceName: FlowlightConstants.machServiceName, options: [])
connection.remoteObjectInterface = NSXPCInterface(with: FlowlightProviderXPC.self)
@@ -110,8 +141,8 @@ final class ExtensionTrafficSource: NSObject, TrafficSource, FlowlightAppXPC, @u
: L("Connected to filter extension %@ — no traffic from it yet", version))
self.startHeartbeat()
self.reportPackStatus(.init(status: .accepted, detail: "Synchronizing managed packs"))
- // Initial synchronization is ordered: explicit user allows must exist before either managed packs or
- // AgentPolicy can block. The extension stays fail-open until the final pack commit/clear acknowledges.
+ // Initial synchronization is ordered: explicit user allows exist before AgentPolicy is enabled.
+ // Managed packs remain independently cleared until their own commit or clear acknowledges.
self.pushRules {
self.pushEnforcement {
self.pushRulePacks()
@@ -216,8 +247,14 @@ final class ExtensionTrafficSource: NSObject, TrafficSource, FlowlightAppXPC, @u
private func pushEnforcement(completion: (() -> Void)? = nil) {
guard let connection else { return }
let payload = enforcement.isEmpty ? Data() : BlockCoding.encode(enforcement)
- let proxy = connection.remoteObjectProxyWithErrorHandler { _ in } as? FlowlightProviderXPC
- proxy?.setEnforcement(payload: payload) { _ in
+ let proxy = connection.remoteObjectProxyWithErrorHandler { [weak self, weak connection] error in
+ self?.scheduleReconnect(L("Extension unreachable: %@", error.localizedDescription), from: connection)
+ } as? FlowlightProviderXPC
+ guard let proxy else {
+ scheduleReconnect(L("Extension unreachable: %@", "policy update"), from: connection)
+ return
+ }
+ proxy.setEnforcement(payload: payload) { [weak self, weak connection] _ in
DispatchQueue.main.async { [weak self, weak connection] in
guard let self, let connection, self.connection === connection else { return }
completion?()
@@ -235,16 +272,56 @@ final class ExtensionTrafficSource: NSObject, TrafficSource, FlowlightAppXPC, @u
private func pushRules(completion: (() -> Void)? = nil) {
guard let connection else { return }
- let payload = ruleSet.rules.isEmpty && ruleSet.pausedUntil == nil ? Data() : BlockCoding.encode(ruleSet)
- let proxy = connection.remoteObjectProxyWithErrorHandler { _ in } as? FlowlightProviderXPC
- proxy?.setRules(payload: payload) { _ in
+ let sentGeneration = ruleSet.generation
+ let payload = BlockCoding.encode(ruleSet)
+ let proxy = connection.remoteObjectProxyWithErrorHandler { [weak self, weak connection] error in
+ self?.scheduleReconnect(L("Extension unreachable: %@", error.localizedDescription), from: connection)
+ } as? FlowlightProviderXPC
+ guard let proxy else {
+ scheduleReconnect(L("Extension unreachable: %@", "rule update"), from: connection)
+ return
+ }
+ proxy.setRules(payload: payload) { [weak self, weak connection] _ in
DispatchQueue.main.async { [weak self, weak connection] in
guard let self, let connection, self.connection === connection else { return }
+ guard self.ruleSet.generation == sentGeneration else {
+ // The initial handshake chains enforcement and packs from this reply. If a newer whole-list
+ // publication overtook it, restart that chain with the current generation instead of stranding
+ // the source half-synchronized.
+ if completion != nil {
+ self.pushRules(completion: completion)
+ }
+ return
+ }
completion?()
}
}
}
+ func claimRule(_ claim: RuleClaim, completion: @escaping @Sendable (Bool?) -> Void) {
+ DispatchQueue.main.async { [weak self] in
+ guard let self, let connection = self.connection else {
+ completion(nil)
+ return
+ }
+ let proxy = connection.remoteObjectProxyWithErrorHandler { _ in completion(nil) }
+ as? FlowlightProviderXPC
+ guard let proxy else {
+ completion(nil)
+ return
+ }
+ proxy.claimRule(payload: BlockCoding.encode(claim)) { [weak self, weak connection] result in
+ DispatchQueue.main.async { [weak self, weak connection] in
+ guard let self, let connection, self.connection === connection else {
+ completion(nil)
+ return
+ }
+ completion(result < 0 ? nil : result == 1)
+ }
+ }
+ }
+ }
+
func setRulePacks(_ snapshot: RulePackSnapshot?) {
DispatchQueue.main.async { [weak self] in
guard let self else { return }
@@ -257,40 +334,76 @@ final class ExtensionTrafficSource: NSObject, TrafficSource, FlowlightAppXPC, @u
private func pushRulePacks() {
guard let connection else { return }
let epoch = rulePackPushEpoch
+ guard rulePackPushGate.begin(epoch: epoch) else { return }
let desiredGeneration = rulePackSnapshot?.generation
let proxy = connection.remoteObjectProxyWithErrorHandler { [weak self, weak connection] error in
DispatchQueue.main.async {
- guard let self, let connection, self.connection === connection,
- self.rulePackPushEpoch == epoch else { return }
- self.reportPackStatus(.init(status: .invalid, detail: error.localizedDescription))
+ guard let self, let connection, self.connection === connection else { return }
+ appLog.error("pack sync XPC failed epoch \(epoch, privacy: .public): \(error.localizedDescription, privacy: .public)")
+ self.finishRulePackPush(
+ epoch: epoch,
+ status: .init(status: .invalid, detail: error.localizedDescription)
+ )
}
} as? FlowlightProviderXPC
- proxy?.rulePackHello(appProtocol: Int(RulePackSnapshot.protocolVersion)) { [weak self, weak connection] data in
+ guard let proxy else {
+ appLog.error("pack sync proxy unavailable epoch \(epoch, privacy: .public)")
+ finishRulePackPush(
+ epoch: epoch,
+ status: .init(status: .invalid, detail: "The extension pack interface is unavailable.")
+ )
+ return
+ }
+ proxy.rulePackHello(appProtocol: Int(RulePackSnapshot.protocolVersion)) { [weak self, weak connection] (data: Data) in
DispatchQueue.main.async {
- guard let self, let connection, self.connection === connection,
- self.rulePackPushEpoch == epoch,
- self.rulePackSnapshot?.generation == desiredGeneration,
- let handshake = BlockCoding.decode(RulePackHandshake.self, from: data) else { return }
+ guard let self, let connection, self.connection === connection else { return }
+ guard self.rulePackPushEpoch == epoch,
+ self.rulePackSnapshot?.generation == desiredGeneration else {
+ self.finishRulePackPush(epoch: epoch)
+ return
+ }
+ guard let handshake = BlockCoding.decode(RulePackHandshake.self, from: data) else {
+ self.finishRulePackPush(
+ epoch: epoch,
+ status: .init(status: .invalid, detail: "The extension returned an unreadable pack handshake.")
+ )
+ return
+ }
guard handshake.protocolVersion == RulePackSnapshot.protocolVersion else {
- self.reportPackStatus(.init(status: .incompatible, activeDigest: handshake.activeDigest,
- detail: "The installed extension does not support this pack format."))
+ self.finishRulePackPush(
+ epoch: epoch,
+ status: .init(
+ status: .incompatible,
+ activeDigest: handshake.activeDigest,
+ detail: "The installed extension does not support this pack format."
+ )
+ )
return
}
guard let snapshot = self.rulePackSnapshot else {
let request = RulePackClearRequest(expectedActiveGeneration: handshake.activeGeneration)
- proxy?.clearRulePacks(payload: BlockCoding.encode(request)) { [weak self, weak connection] data in
+ proxy.clearRulePacks(payload: BlockCoding.encode(request)) { [weak self, weak connection] data in
DispatchQueue.main.async {
- guard let self, let connection, self.connection === connection,
- self.rulePackPushEpoch == epoch, self.rulePackSnapshot == nil else { return }
- self.decodePackStatus(data)
+ guard let self, let connection, self.connection === connection else { return }
+ guard self.rulePackPushEpoch == epoch, self.rulePackSnapshot == nil else {
+ self.finishRulePackPush(epoch: epoch)
+ return
+ }
+ self.finishRulePackPush(epoch: epoch, statusData: data)
}
}
return
}
guard handshake.activeDigest != snapshot.digest else {
- self.reportPackStatus(.init(status: .committed, generation: handshake.activeGeneration,
- activeDigest: handshake.activeDigest,
- activePacks: snapshot.configurations.filter(\.enabled).count))
+ self.finishRulePackPush(
+ epoch: epoch,
+ status: .init(
+ status: .committed,
+ generation: handshake.activeGeneration,
+ activeDigest: handshake.activeDigest,
+ activePacks: snapshot.configurations.filter(\.enabled).count
+ )
+ )
return
}
let payload = BlockCoding.encode(snapshot)
@@ -302,13 +415,16 @@ final class ExtensionTrafficSource: NSObject, TrafficSource, FlowlightAppXPC, @u
generation: snapshot.generation, digest: snapshot.digest,
totalBytes: payload.count, chunks: chunks.count,
expectedActiveGeneration: handshake.activeGeneration)
- proxy?.beginRulePack(payload: BlockCoding.encode(header)) { [weak self, weak connection] reply in
+ proxy.beginRulePack(payload: BlockCoding.encode(header)) { [weak self, weak connection] reply in
DispatchQueue.main.async {
- guard let self, let connection, self.connection === connection,
- self.rulePackPushEpoch == epoch,
- self.rulePackSnapshot?.generation == snapshot.generation else { return }
+ guard let self, let connection, self.connection === connection else { return }
+ guard self.rulePackPushEpoch == epoch,
+ self.rulePackSnapshot?.generation == snapshot.generation else {
+ self.finishRulePackPush(epoch: epoch)
+ return
+ }
guard BlockCoding.decode(RulePackApplyResult.self, from: reply)?.status == .accepted else {
- self.decodePackStatus(reply)
+ self.finishRulePackPush(epoch: epoch, statusData: reply)
return
}
self.appendPackChunks(chunks, index: 0, snapshot: snapshot, epoch: epoch,
@@ -320,29 +436,44 @@ final class ExtensionTrafficSource: NSObject, TrafficSource, FlowlightAppXPC, @u
}
private func appendPackChunks(_ chunks: [Data], index: Int, snapshot: RulePackSnapshot, epoch: UInt64,
- connection: NSXPCConnection, proxy: FlowlightProviderXPC?) {
+ connection: NSXPCConnection, proxy: FlowlightProviderXPC) {
guard self.connection === connection, rulePackPushEpoch == epoch,
- rulePackSnapshot?.generation == snapshot.generation else { return }
+ rulePackSnapshot?.generation == snapshot.generation else {
+ finishRulePackPush(epoch: epoch)
+ return
+ }
guard index < chunks.count else {
- proxy?.commitRulePack(generation: snapshot.generation.uuidString) { [weak self, weak connection] data in
+ proxy.commitRulePack(generation: snapshot.generation.uuidString) { [weak self, weak connection] data in
DispatchQueue.main.async {
- guard let self, let connection, self.connection === connection,
- self.rulePackPushEpoch == epoch,
- self.rulePackSnapshot?.generation == snapshot.generation else { return }
- self.decodePackStatus(data)
+ guard let self, let connection, self.connection === connection else { return }
+ guard self.rulePackPushEpoch == epoch,
+ self.rulePackSnapshot?.generation == snapshot.generation else {
+ self.finishRulePackPush(epoch: epoch)
+ return
+ }
+ self.finishRulePackPush(epoch: epoch, statusData: data)
}
}
return
}
- proxy?.appendRulePack(generation: snapshot.generation.uuidString, index: index, payload: chunks[index]) {
+ proxy.appendRulePack(generation: snapshot.generation.uuidString, index: index, payload: chunks[index]) {
[weak self, weak connection] accepted in
DispatchQueue.main.async {
- guard let self, let connection, self.connection === connection,
- self.rulePackPushEpoch == epoch,
- self.rulePackSnapshot?.generation == snapshot.generation else { return }
+ guard let self, let connection, self.connection === connection else { return }
+ guard self.rulePackPushEpoch == epoch,
+ self.rulePackSnapshot?.generation == snapshot.generation else {
+ self.finishRulePackPush(epoch: epoch)
+ return
+ }
guard accepted else {
- self.reportPackStatus(.init(status: .invalid, generation: snapshot.generation,
- detail: "The extension rejected pack chunk \(index + 1)."))
+ self.finishRulePackPush(
+ epoch: epoch,
+ status: .init(
+ status: .invalid,
+ generation: snapshot.generation,
+ detail: "The extension rejected pack chunk \(index + 1)."
+ )
+ )
return
}
self.appendPackChunks(chunks, index: index + 1, snapshot: snapshot, epoch: epoch,
@@ -351,25 +482,42 @@ final class ExtensionTrafficSource: NSObject, TrafficSource, FlowlightAppXPC, @u
}
}
- private func decodePackStatus(_ data: Data) {
- guard let status = BlockCoding.decode(RulePackApplyResult.self, from: data) else {
- reportPackStatus(.init(status: .invalid, detail: "The extension returned an unreadable pack status."))
- return
+ private func finishRulePackPush(epoch: UInt64, statusData: Data) {
+ let status = BlockCoding.decode(RulePackApplyResult.self, from: statusData)
+ ?? .init(status: .invalid, detail: "The extension returned an unreadable pack status.")
+ finishRulePackPush(epoch: epoch, status: status)
+ }
+
+ private func finishRulePackPush(epoch: UInt64, status: RulePackApplyResult? = nil) {
+ guard let hasPendingNewerEpoch = rulePackPushGate.finish(epoch: epoch) else { return }
+ if let status {
+ if status.status == .stale {
+ reportPackStatus(
+ .init(
+ status: .accepted,
+ generation: status.generation,
+ activeDigest: status.activeDigest,
+ detail: status.detail
+ )
+ )
+ } else {
+ reportPackStatus(status)
+ }
}
- if status.status == .stale {
- reportPackStatus(.init(status: .accepted, generation: status.generation,
- activeDigest: status.activeDigest, detail: status.detail))
- DispatchQueue.main.async { [weak self] in self?.pushRulePacks() }
- } else {
- reportPackStatus(status)
+ if hasPendingNewerEpoch || status?.status == .stale {
+ pushRulePacks()
}
}
private func reportPackStatus(_ status: RulePackApplyResult) {
- DispatchQueue.main.async { [weak self] in self?.onRulePackStatus?(status) }
+ if Thread.isMainThread {
+ onRulePackStatus?(status)
+ } else {
+ DispatchQueue.main.async { [weak self] in self?.onRulePackStatus?(status) }
+ }
}
- func rulePackEvents(payload: Data, reply: @escaping (Bool) -> Void) {
+ func rulePackEvents(payload: Data, reply: @escaping @Sendable (Bool) -> Void) {
guard let batch = BlockCoding.decode(RulePackEventBatch.self, from: payload),
let onRulePackEvents else {
reply(false)
diff --git a/Flowlight/Capture/TrafficSource.swift b/Flowlight/Capture/TrafficSource.swift
index 4b7f190f..25810737 100644
--- a/Flowlight/Capture/TrafficSource.swift
+++ b/Flowlight/Capture/TrafficSource.swift
@@ -12,6 +12,8 @@ protocol TrafficSource: AnyObject {
/// The rule list this source should carry out. Same story as `setEnforcement`: only the Network Extension is
/// in the data path, and the Rules screen says so per rule rather than pretending.
func setRules(_ rules: RuleSet)
+ /// Atomically spends one bounded flow rule. Nil means this source cannot authoritatively claim it.
+ func claimRule(_ claim: RuleClaim, completion: @escaping @Sendable (Bool?) -> Void)
/// One complete managed-pack generation. Nil clears packs. Other capture sources ignore it and the UI reports
/// watching-only instead of claiming that the setting is enforced.
func setRulePacks(_ snapshot: RulePackSnapshot?)
@@ -20,6 +22,7 @@ protocol TrafficSource: AnyObject {
extension TrafficSource {
func setEnforcement(_ policies: [AgentPolicy]) {}
func setRules(_ rules: RuleSet) {}
+ func claimRule(_ claim: RuleClaim, completion: @escaping @Sendable (Bool?) -> Void) { completion(nil) }
func setRulePacks(_ snapshot: RulePackSnapshot?) {}
}
diff --git a/Flowlight/Inspection/InspectionController.swift b/Flowlight/Inspection/InspectionController.swift
index 463e01bd..70732fbd 100644
--- a/Flowlight/Inspection/InspectionController.swift
+++ b/Flowlight/Inspection/InspectionController.swift
@@ -1,6 +1,56 @@
import AppKit
import Foundation
+/// Socket ownership can lag connection acceptance briefly while libproc publishes the new descriptor. Keep the
+/// authorization wait bounded, but give app-scoped rules and managed packs more than one scheduling instant to find it.
+enum ProxyOwnerLookup {
+ typealias Scheduler = @Sendable (
+ TimeInterval,
+ @escaping @Sendable () -> Void
+ ) -> Void
+
+ static let retryDelays: [TimeInterval] = [0.01, 0.025, 0.05]
+
+ static func resolve(
+ lookup: @escaping @Sendable () -> InspectionRecorder.Owner,
+ completion: @escaping @Sendable (InspectionRecorder.Owner) -> Void
+ ) {
+ resolve(
+ delays: retryDelays[...],
+ lookup: lookup,
+ schedule: { delay, work in
+ DispatchQueue.global(qos: .userInitiated).asyncAfter(
+ deadline: .now() + delay,
+ execute: work
+ )
+ },
+ completion: completion
+ )
+ }
+
+ static func resolve(
+ delays: ArraySlice,
+ lookup: @escaping @Sendable () -> InspectionRecorder.Owner,
+ schedule: @escaping Scheduler,
+ completion: @escaping @Sendable (InspectionRecorder.Owner) -> Void
+ ) {
+ let owner = lookup()
+ guard owner.bundleID.isEmpty,
+ let delay = delays.first else {
+ completion(owner)
+ return
+ }
+ schedule(delay) {
+ resolve(
+ delays: delays.dropFirst(),
+ lookup: lookup,
+ schedule: schedule,
+ completion: completion
+ )
+ }
+ }
+}
+
/// HTTPS inspection: an opt-in mode, off by default, that decrypts the traffic of apps routed through Flowlight's
/// local proxy using a certificate authority created on this Mac. Everything it records stays in the local database.
@MainActor
@@ -64,8 +114,28 @@ final class InspectionController: ObservableObject {
/// which only the proxy can see. Read fresh on every connection, so a rule written now applies to the next
/// request rather than the next launch.
nonisolated(unsafe) var requestRules: @Sendable () -> [Rule] = { [] }
- /// Called with every request a rule refused, ready to be recorded in the violations feed.
+ /// Called with every request or proxied connection a rule refused, ready for the violations feed.
nonisolated(unsafe) var onRuleRefusal: @Sendable (RuleEvent) -> Void = { _ in }
+ /// One atomic flow-level rule answer, including whether the global escape hatch is active.
+ nonisolated(unsafe) var flowRuleDecision: @Sendable (FlowFacts) -> LiveFlowRuleDecision = {
+ _ in LiveFlowRuleDecision(paused: false, decision: RuleDecision(verdict: .allow, rule: nil), externalClaim: nil)
+ }
+ /// Bounded rules are previewed locally, then atomically claimed by the active enforcement source.
+ nonisolated(unsafe) var claimFlowRule: @Sendable (
+ RuleClaim,
+ @escaping @Sendable (Bool?) -> Void
+ ) -> Void = { _, completion in completion(nil) }
+ nonisolated(unsafe) var flowRuleClaimed: @Sendable (RuleClaim) -> Void = { _ in }
+ nonisolated(unsafe) var flowRuleSpent: @Sendable (
+ RuleClaim,
+ @escaping @Sendable (Bool) -> Void
+ ) -> Void = { _, completion in completion(false) }
+ /// The exact extension-committed managed-pack generation, queried with the original proxied app identity.
+ nonisolated(unsafe) var rulePackMatch: @Sendable (String, String) -> RulePackMatch? = { _, _ in nil }
+ /// Persists a proxy pack refusal before the proxy acts. False means fail open: no invisible refusal.
+ nonisolated(unsafe) var onRulePackRefusal: @Sendable (RulePackEvent, @escaping @Sendable (Bool) -> Void) -> Void = {
+ _, completion in completion(false)
+ }
/// The guardrails in force, read fresh per connection like the rules.
nonisolated(unsafe) var guardrails: @Sendable () -> [Guardrail] = { [] }
/// Called with every tool a guardrail took away, and every call it refused.
@@ -104,8 +174,10 @@ final class InspectionController: ObservableObject {
// A rule refusing a request is answered by the same machinery that gives a mock its canned response, and
// it goes first: a block someone wrote has to outrank a mock they left switched on.
let answersFor = { [weak self, recorder, proxy] (host: String, clientPort: UInt16) -> [MockRule] in
+ let destination = Self.proxyDestination(host)
var refusals = (self?.requestRules() ?? []).filter {
- $0.action == .block && ($0.destination.isEmpty || AgentPolicy.matches($0.destination, host: host, ip: ""))
+ $0.action == .block && ($0.destination.isEmpty
+ || AgentPolicy.matches($0.destination, host: destination.host, ip: destination.ip))
}
if refusals.contains(where: { !$0.app.isEmpty }) {
// Only then is it worth asking who opened this connection: naming an app is the uncommon case,
@@ -119,6 +191,14 @@ final class InspectionController: ObservableObject {
return refusals.map { $0.asRefusal(host: host) } + MockRules.mocks(mockRules(), host: host)
}
proxy.mockRules = { host, clientPort in answersFor(host, clientPort) }
+ proxy.authorizeConnection = { [weak self, recorder, proxy] host, port, clientPort, answer in
+ guard let self else { answer(true); return }
+ ProxyOwnerLookup.resolve(
+ lookup: { recorder.owner(clientPort: clientPort, proxyPort: proxy.port) }
+ ) { owner in
+ self.authorizeProxyConnection(host: host, port: port, owner: owner, answer: answer)
+ }
+ }
// Guardrails read and change whole requests, which is a different job from matching a URL, so they get
// their own hook rather than being squeezed into the answer machinery.
proxy.interventions = { [weak self, recorder, proxy] host, clientPort in
@@ -456,24 +536,27 @@ final class InspectionController: ObservableObject {
proxy.stop()
return
}
- Task.detached(priority: .userInitiated) {
- var failure: String?
- var trustChanged = false
- // Trust first: it has its own dialog, and there's no point changing the proxy if it's refused.
- if needsTrustChange {
- do { try certificate.setTrusted(on); trustChanged = true } catch { failure = error.localizedDescription }
- }
- if failure == nil, needsProxyChange {
- var error: NSDictionary?
- NSAppleScript(source: script)?.executeAndReturnError(&error)
- failure = error.flatMap { e -> String? in
- (e[NSAppleScript.errorNumber] as? Int) == -128 ? "cancelled"
- : (e[NSAppleScript.errorMessage] as? String ?? L("authorization failed"))
+ Task {
+ let failure = await Task.detached(priority: .userInitiated) {
+ var failure: String?
+ var trustChanged = false
+ // Trust first: it has its own dialog, and there's no point changing the proxy if it's refused.
+ if needsTrustChange {
+ do { try certificate.setTrusted(on); trustChanged = true } catch { failure = error.localizedDescription }
}
- // Don't leave the certificate trusted for a proxy that was never set up.
- if failure != nil, trustChanged { try? certificate.setTrusted(!on) }
- }
- await MainActor.run { self.finishSetup(on: on, failure: failure) }
+ if failure == nil, needsProxyChange {
+ var error: NSDictionary?
+ NSAppleScript(source: script)?.executeAndReturnError(&error)
+ failure = error.flatMap { e -> String? in
+ (e[NSAppleScript.errorNumber] as? Int) == -128 ? "cancelled"
+ : (e[NSAppleScript.errorMessage] as? String ?? L("authorization failed"))
+ }
+ // Don't leave the certificate trusted for a proxy that was never set up.
+ if failure != nil, trustChanged { try? certificate.setTrusted(!on) }
+ }
+ return failure
+ }.value
+ finishSetup(on: on, failure: failure)
}
}
@@ -770,6 +853,111 @@ final class InspectionController: ObservableObject {
// MARK: Matching
+ nonisolated private func authorizeProxyConnection(
+ host: String,
+ port: Int,
+ owner: InspectionRecorder.Owner,
+ answer: @escaping @Sendable (Bool) -> Void
+ ) {
+ guard !owner.bundleID.isEmpty else { answer(true); return }
+ let facts = Self.proxyFlowFacts(host: host, port: port, owner: owner)
+ guard !BlockRules.isExempt(facts) else { answer(true); return }
+ let ruled = flowRuleDecision(facts)
+ guard !ruled.paused else { answer(true); return }
+ if let rule = ruled.decision.rule {
+ authorizeProxyRule(
+ rule,
+ externalClaim: ruled.externalClaim,
+ facts: facts,
+ owner: owner,
+ answer: answer
+ )
+ return
+ }
+ guard let match = rulePackMatch(host, owner.bundleID) else { answer(true); return }
+ let now = Int64(Date().timeIntervalSince1970)
+ let event = RulePackEvent(bucket: now / 60 * 60, packID: match.packID,
+ packVersion: match.packVersion, entryID: match.entry.id,
+ category: match.entry.category, match: match.entry.match, action: .block,
+ count: 1, firstAt: now, lastAt: now, bundleID: owner.bundleID,
+ appName: owner.appName, agentKey: facts.agentKey,
+ agentName: owner.agentName ?? owner.appName, host: match.matchedHost,
+ matchedHost: match.entry.host, port: facts.port)
+ onRulePackRefusal(event) { committed in answer(!committed) }
+ }
+
+ nonisolated private func authorizeProxyRule(
+ _ rule: Rule,
+ externalClaim: RuleClaim?,
+ facts: FlowFacts,
+ owner: InspectionRecorder.Owner,
+ answer: @escaping @Sendable (Bool) -> Void
+ ) {
+ let finish: @Sendable (Rule) -> Void = { [onRuleRefusal] rule in
+ onRuleRefusal(RuleEvent(at: Int64(Date().timeIntervalSince1970), ruleID: rule.id,
+ ruleSession: externalClaim?.session,
+ ruleGeneration: externalClaim?.generation,
+ action: rule.action, engine: .flow, agentKey: facts.agentKey,
+ agentName: owner.agentName ?? owner.appName, appName: owner.appName,
+ bundleID: owner.bundleID, host: facts.host, ip: facts.ip, port: facts.port))
+ answer(rule.action != .block)
+ }
+ guard let externalClaim else {
+ finish(rule)
+ return
+ }
+ claimFlowRule(externalClaim) { [weak self] claimed in
+ guard let self else { answer(true); return }
+ guard claimed == true else {
+ // Another flow spent this rule first. An uncertain reply may also mean this exact claim committed
+ // before XPC failed; retire it conservatively so reconnect cannot reuse a bounded allowance. Both
+ // paths fail this connection open unless a definitive "already spent" result supports re-decision.
+ self.flowRuleSpent(externalClaim) { persisted in
+ guard persisted, claimed == false else {
+ answer(true)
+ return
+ }
+ let next = self.flowRuleDecision(facts)
+ guard !next.paused, let nextRule = next.decision.rule else {
+ answer(true)
+ return
+ }
+ self.authorizeProxyRule(
+ nextRule,
+ externalClaim: next.externalClaim,
+ facts: facts,
+ owner: owner,
+ answer: answer
+ )
+ }
+ return
+ }
+ self.flowRuleClaimed(externalClaim)
+ finish(rule)
+ }
+ }
+
+ nonisolated static func proxyDestination(_ host: String) -> (host: String, ip: String) {
+ guard let address = HostCanonicalizer.address(host) else { return (host, "") }
+ return ("", address)
+ }
+
+ nonisolated static func proxyFlowFacts(
+ host: String,
+ port: Int,
+ owner: InspectionRecorder.Owner
+ ) -> FlowFacts {
+ let destination = proxyDestination(host)
+ return FlowFacts(
+ agentKey: owner.agent ?? owner.bundleID,
+ bundleID: owner.bundleID,
+ host: destination.host,
+ ip: destination.ip,
+ port: UInt16(clamping: port),
+ hostSettled: true
+ )
+ }
+
nonisolated static func matches(host: String, patterns: [String]) -> Bool {
let host = host.lowercased()
return patterns.contains { raw in
diff --git a/Flowlight/Inspection/InspectionProxy.swift b/Flowlight/Inspection/InspectionProxy.swift
index 3777928f..b46e3d7e 100644
--- a/Flowlight/Inspection/InspectionProxy.swift
+++ b/Flowlight/Inspection/InspectionProxy.swift
@@ -57,6 +57,12 @@ final class InspectionProxy: @unchecked Sendable {
private var pinned: [String: Date] = [:]
weak var observer: ProxyObserver?
+ /// Decides whether a client connection may reach its origin. The answer can wait for a durable refusal record;
+ /// routing resumes on the proxy queue and no upstream socket exists before an allow answer.
+ var authorizeConnection: @Sendable (_ host: String, _ port: Int, _ clientPort: UInt16,
+ _ answer: @escaping @Sendable (Bool) -> Void) -> Void = {
+ _, _, _, answer in answer(true)
+ }
/// Decides whether to decrypt a CONNECT to `host` from the client at `clientPort`. May answer asynchronously
/// (it can look up the owning process); the proxy continues on its own queue.
var shouldInspect: (_ host: String, _ clientPort: UInt16, _ answer: @escaping (Bool) -> Void) -> Void = { _, _, answer in answer(true) }
@@ -150,20 +156,26 @@ final class InspectionProxy: @unchecked Sendable {
}
if head.method == "CONNECT" {
guard let (host, port) = head.authority else { respond(client, status: "400 Bad Request"); return }
- let notPinned = pinned[host].map { $0 < Date() } ?? true
- let proceed: (Bool) -> Void = { [weak self] inspect in
+ authorizeConnection(host, port, clientPort) { [weak self] allowed in
self?.queue.async {
- client.send(content: Data("HTTP/1.1 200 Connection Established\r\n\r\n".utf8), completion: .contentProcessed { error in
- guard let self, error == nil else { client.cancel(); return }
- if inspect && notPinned {
- self.bridge(client, host: host, port: port, clientPort: clientPort, early: rest)
- } else {
- self.tunnel(client, host: host, port: port, clientPort: clientPort, early: rest)
+ guard let self else { client.cancel(); return }
+ guard allowed else { self.respond(client, status: "403 Forbidden"); return }
+ let notPinned = self.pinned[host].map { $0 < Date() } ?? true
+ let proceed: (Bool) -> Void = { [weak self] inspect in
+ self?.queue.async {
+ client.send(content: Data("HTTP/1.1 200 Connection Established\r\n\r\n".utf8), completion: .contentProcessed { error in
+ guard let self, error == nil else { client.cancel(); return }
+ if inspect && notPinned {
+ self.bridge(client, host: host, port: port, clientPort: clientPort, early: rest)
+ } else {
+ self.tunnel(client, host: host, port: port, clientPort: clientPort, early: rest)
+ }
+ })
}
- })
+ }
+ if notPinned { self.shouldInspect(host, clientPort, proceed) } else { proceed(false) }
}
}
- if notPinned { shouldInspect(host, clientPort, proceed) } else { proceed(false) }
} else if head.target.hasPrefix("/") {
if head.target.hasPrefix("/proxy.pac") {
let body = Data(pacScript().utf8)
@@ -172,13 +184,20 @@ final class InspectionProxy: @unchecked Sendable {
respond(client, status: "404 Not Found")
}
} else if let url = URL(string: head.target), url.scheme == "http", let host = url.host {
- // Plain HTTP through the proxy: forward it and watch it like an inspected flow.
+ // Plain HTTP through the proxy: decide before an origin socket exists, then forward and observe it.
let port = url.port ?? 80
- let flow = ProxyFlow(host: host, port: port, clientPort: clientPort, inspected: true, scheme: "http")
- var request = head.originForm()
- request.append(rest)
- let upstream = NWConnection(host: .init(host), port: .init(integerLiteral: UInt16(clamping: port)), using: Self.direct(.tcp))
- relay(client: client, upstream: upstream, flow: flow, firstClientBytes: request)
+ authorizeConnection(host, port, clientPort) { [weak self] allowed in
+ self?.queue.async {
+ guard let self else { client.cancel(); return }
+ guard allowed else { self.respond(client, status: "403 Forbidden"); return }
+ let flow = ProxyFlow(host: host, port: port, clientPort: clientPort, inspected: true, scheme: "http")
+ var request = head.originForm()
+ request.append(rest)
+ let upstream = NWConnection(host: .init(host), port: .init(integerLiteral: UInt16(clamping: port)),
+ using: Self.direct(.tcp))
+ self.relay(client: client, upstream: upstream, flow: flow, firstClientBytes: request)
+ }
+ }
} else {
respond(client, status: "400 Bad Request")
}
diff --git a/Flowlight/Inspection/InspectionRecorder.swift b/Flowlight/Inspection/InspectionRecorder.swift
index 333d0d91..686a50c5 100644
--- a/Flowlight/Inspection/InspectionRecorder.swift
+++ b/Flowlight/Inspection/InspectionRecorder.swift
@@ -305,6 +305,9 @@ final class InspectionRecorder: ProxyObserver, @unchecked Sendable {
if let hit = owners[clientPort], Date().timeIntervalSince(hit.at) < 30 { ownersLock.unlock(); return hit.owner }
ownersLock.unlock()
let owner = lookupOwner(clientPort: clientPort, proxyPort: proxyPort)
+ // The socket can reach the listener just before libproc publishes it. Do not turn that timing miss into a
+ // 30-second cached identity-less owner; authorization gets a bounded chance to ask again.
+ guard !owner.bundleID.isEmpty else { return owner }
ownersLock.lock()
if owners.count > 2000 { owners.removeAll() }
owners[clientPort] = (owner, Date())
diff --git a/Flowlight/Localization/de.lproj/Localizable.strings b/Flowlight/Localization/de.lproj/Localizable.strings
index 65fbd3dd..e196cd3d 100644
--- a/Flowlight/Localization/de.lproj/Localizable.strings
+++ b/Flowlight/Localization/de.lproj/Localizable.strings
@@ -1733,3 +1733,4 @@
"Plugins add advisory findings to inspected exchanges. They do not block, rewrite, mock, transform, save settings, or send traffic anywhere by themselves." = "Plugins add advisory findings to inspected exchanges. They do not block, rewrite, mock, transform, save settings, or send traffic anywhere by themselves.";
"Suggests reviewed actions" = "Suggests reviewed actions";
"Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic." = "Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic.";
+"Couldn't delete the captured data: %@" = "Die erfassten Daten konnten nicht gelöscht werden: %@";
diff --git a/Flowlight/Localization/en.lproj/Localizable.strings b/Flowlight/Localization/en.lproj/Localizable.strings
index 10f7b355..76c31ba6 100644
--- a/Flowlight/Localization/en.lproj/Localizable.strings
+++ b/Flowlight/Localization/en.lproj/Localizable.strings
@@ -1732,3 +1732,4 @@
"Plugins add advisory findings to inspected exchanges. They do not block, rewrite, mock, transform, save settings, or send traffic anywhere by themselves." = "Plugins add advisory findings to inspected exchanges. They do not block, rewrite, mock, transform, save settings, or send traffic anywhere by themselves.";
"Suggests reviewed actions" = "Suggests reviewed actions";
"Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic." = "Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic.";
+"Couldn't delete the captured data: %@" = "Couldn't delete the captured data: %@";
diff --git a/Flowlight/Localization/es.lproj/Localizable.strings b/Flowlight/Localization/es.lproj/Localizable.strings
index 81d3c422..0337a9f0 100644
--- a/Flowlight/Localization/es.lproj/Localizable.strings
+++ b/Flowlight/Localization/es.lproj/Localizable.strings
@@ -1733,3 +1733,4 @@
"Plugins add advisory findings to inspected exchanges. They do not block, rewrite, mock, transform, save settings, or send traffic anywhere by themselves." = "Plugins add advisory findings to inspected exchanges. They do not block, rewrite, mock, transform, save settings, or send traffic anywhere by themselves.";
"Suggests reviewed actions" = "Suggests reviewed actions";
"Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic." = "Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic.";
+"Couldn't delete the captured data: %@" = "No se pudieron eliminar los datos capturados: %@";
diff --git a/Flowlight/Localization/fr.lproj/Localizable.strings b/Flowlight/Localization/fr.lproj/Localizable.strings
index 9808dd4e..f4b11181 100644
--- a/Flowlight/Localization/fr.lproj/Localizable.strings
+++ b/Flowlight/Localization/fr.lproj/Localizable.strings
@@ -1733,3 +1733,4 @@
"Plugins add advisory findings to inspected exchanges. They do not block, rewrite, mock, transform, save settings, or send traffic anywhere by themselves." = "Plugins add advisory findings to inspected exchanges. They do not block, rewrite, mock, transform, save settings, or send traffic anywhere by themselves.";
"Suggests reviewed actions" = "Suggests reviewed actions";
"Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic." = "Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic.";
+"Couldn't delete the captured data: %@" = "Impossible de supprimer les données capturées : %@";
diff --git a/Flowlight/Localization/it.lproj/Localizable.strings b/Flowlight/Localization/it.lproj/Localizable.strings
index 2b889fd9..c23db4c4 100644
--- a/Flowlight/Localization/it.lproj/Localizable.strings
+++ b/Flowlight/Localization/it.lproj/Localizable.strings
@@ -1733,3 +1733,4 @@
"Plugins add advisory findings to inspected exchanges. They do not block, rewrite, mock, transform, save settings, or send traffic anywhere by themselves." = "Plugins add advisory findings to inspected exchanges. They do not block, rewrite, mock, transform, save settings, or send traffic anywhere by themselves.";
"Suggests reviewed actions" = "Suggests reviewed actions";
"Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic." = "Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic.";
+"Couldn't delete the captured data: %@" = "Impossibile eliminare i dati acquisiti: %@";
diff --git a/Flowlight/Localization/ja.lproj/Localizable.strings b/Flowlight/Localization/ja.lproj/Localizable.strings
index eb10aef3..3a47e31b 100644
--- a/Flowlight/Localization/ja.lproj/Localizable.strings
+++ b/Flowlight/Localization/ja.lproj/Localizable.strings
@@ -1733,3 +1733,4 @@
"Plugins add advisory findings to inspected exchanges. They do not block, rewrite, mock, transform, save settings, or send traffic anywhere by themselves." = "Plugins add advisory findings to inspected exchanges. They do not block, rewrite, mock, transform, save settings, or send traffic anywhere by themselves.";
"Suggests reviewed actions" = "Suggests reviewed actions";
"Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic." = "Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic.";
+"Couldn't delete the captured data: %@" = "キャプチャしたデータを削除できませんでした:%@";
diff --git a/Flowlight/Localization/ko.lproj/Localizable.strings b/Flowlight/Localization/ko.lproj/Localizable.strings
index cf4b4d19..ae457c6a 100644
--- a/Flowlight/Localization/ko.lproj/Localizable.strings
+++ b/Flowlight/Localization/ko.lproj/Localizable.strings
@@ -1732,3 +1732,4 @@
"Plugins add advisory findings to inspected exchanges. They do not block, rewrite, mock, transform, save settings, or send traffic anywhere by themselves." = "Plugins add advisory findings to inspected exchanges. They do not block, rewrite, mock, transform, save settings, or send traffic anywhere by themselves.";
"Suggests reviewed actions" = "Suggests reviewed actions";
"Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic." = "Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic.";
+"Couldn't delete the captured data: %@" = "캡처한 데이터를 삭제할 수 없습니다: %@";
diff --git a/Flowlight/Localization/pt-PT.lproj/Localizable.strings b/Flowlight/Localization/pt-PT.lproj/Localizable.strings
index 2a292836..6e2dd1ff 100644
--- a/Flowlight/Localization/pt-PT.lproj/Localizable.strings
+++ b/Flowlight/Localization/pt-PT.lproj/Localizable.strings
@@ -1732,3 +1732,4 @@
"Plugins add advisory findings to inspected exchanges. They do not block, rewrite, mock, transform, save settings, or send traffic anywhere by themselves." = "Plugins add advisory findings to inspected exchanges. They do not block, rewrite, mock, transform, save settings, or send traffic anywhere by themselves.";
"Suggests reviewed actions" = "Suggests reviewed actions";
"Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic." = "Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic.";
+"Couldn't delete the captured data: %@" = "Não foi possível eliminar os dados capturados: %@";
diff --git a/Flowlight/Localization/zh-Hans.lproj/Localizable.strings b/Flowlight/Localization/zh-Hans.lproj/Localizable.strings
index 4840ab5e..7971eba9 100644
--- a/Flowlight/Localization/zh-Hans.lproj/Localizable.strings
+++ b/Flowlight/Localization/zh-Hans.lproj/Localizable.strings
@@ -1732,3 +1732,4 @@
"Plugins add advisory findings to inspected exchanges. They do not block, rewrite, mock, transform, save settings, or send traffic anywhere by themselves." = "待翻译:Plugins add advisory findings to inspected exchanges. They do not block, rewrite, mock, transform, save settings, or send traffic anywhere by themselves.";
"Suggests reviewed actions" = "待翻译:Suggests reviewed actions";
"Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic." = "待翻译:Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic.";
+"Couldn't delete the captured data: %@" = "无法删除捕获的数据:%@";
diff --git a/Flowlight/Localization/zh-Hant.lproj/Localizable.strings b/Flowlight/Localization/zh-Hant.lproj/Localizable.strings
index 29d56cf1..ec4ca334 100644
--- a/Flowlight/Localization/zh-Hant.lproj/Localizable.strings
+++ b/Flowlight/Localization/zh-Hant.lproj/Localizable.strings
@@ -1732,3 +1732,4 @@
"Plugins add advisory findings to inspected exchanges. They do not block, rewrite, mock, transform, save settings, or send traffic anywhere by themselves." = "Plugins add advisory findings to inspected exchanges. They do not block, rewrite, mock, transform, save settings, or send traffic anywhere by themselves.";
"Suggests reviewed actions" = "Suggests reviewed actions";
"Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic." = "Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic.";
+"Couldn't delete the captured data: %@" = "無法刪除擷取的資料:%@";
diff --git a/Flowlight/Storage/TrafficDatabase.swift b/Flowlight/Storage/TrafficDatabase.swift
index 5ed19734..e29ed3ac 100644
--- a/Flowlight/Storage/TrafficDatabase.swift
+++ b/Flowlight/Storage/TrafficDatabase.swift
@@ -1344,21 +1344,23 @@ final class TrafficDatabase: @unchecked Sendable {
func recordRulePackEventBatch(_ batch: RulePackEventBatch) throws -> Bool {
var inserted = false
try conn.transaction {
- let session = batch.deliverySession.uuidString
- let watermark = try conn.query(
- "SELECT sequence FROM rule_pack_event_watermarks WHERE delivery_session = ?",
- [.text(session)], map: { $0.int(0) }
- ).first ?? 0
- guard batch.sequence > watermark else { return }
- try conn.run("""
- INSERT INTO rule_pack_event_watermarks
- (delivery_session, sequence, received, dropped_hits, dropped_keys) VALUES (?,?,?,?,?)
- ON CONFLICT(delivery_session) DO UPDATE SET
- sequence = excluded.sequence, received = excluded.received,
- dropped_hits = dropped_hits + excluded.dropped_hits,
- dropped_keys = dropped_keys + excluded.dropped_keys
- """, [.text(session), .int(batch.sequence), .int(Int64(Date().timeIntervalSince1970)),
- .int(batch.droppedHits), .int(batch.droppedKeys)])
+ if batch.requiresDurableWatermark {
+ let session = batch.deliverySession.uuidString
+ let watermark = try conn.query(
+ "SELECT sequence FROM rule_pack_event_watermarks WHERE delivery_session = ?",
+ [.text(session)], map: { $0.int(0) }
+ ).first ?? 0
+ guard batch.sequence > watermark else { return }
+ try conn.run("""
+ INSERT INTO rule_pack_event_watermarks
+ (delivery_session, sequence, received, dropped_hits, dropped_keys) VALUES (?,?,?,?,?)
+ ON CONFLICT(delivery_session) DO UPDATE SET
+ sequence = excluded.sequence, received = excluded.received,
+ dropped_hits = dropped_hits + excluded.dropped_hits,
+ dropped_keys = dropped_keys + excluded.dropped_keys
+ """, [.text(session), .int(batch.sequence), .int(Int64(Date().timeIntervalSince1970)),
+ .int(batch.droppedHits), .int(batch.droppedKeys)])
+ }
try recordRulePackEvents(batch.events, inTransaction: true)
inserted = true
}
@@ -2040,7 +2042,7 @@ final class TrafficDatabase: @unchecked Sendable {
func clearAll() throws {
try conn.transaction {
- for t in Self.tables + ["rollup_state", "seen_destinations", "seen_ports", "apps", "baselines", "alerts", "http_exchanges", "local_risk_assessments", "plugin_findings", "rule_pack_event_rollups"] {
+ for t in Self.tables + ["rollup_state", "seen_destinations", "seen_ports", "apps", "baselines", "alerts", "http_exchanges", "local_risk_assessments", "plugin_findings", "rule_events", "rule_pack_event_rollups"] {
try conn.run("DELETE FROM \(t)")
}
// Keep event batch IDs so an acknowledgment lost before "Delete everything" cannot resurrect activity.
diff --git a/FlowlightExtension/BlockEnforcer.swift b/FlowlightExtension/BlockEnforcer.swift
index 282bc176..4e43a221 100644
--- a/FlowlightExtension/BlockEnforcer.swift
+++ b/FlowlightExtension/BlockEnforcer.swift
@@ -62,10 +62,29 @@ final class BlockEnforcer: @unchecked Sendable {
func apply(_ set: RuleSet) -> Int {
lock.lock(); defer { lock.unlock() }
ruleSet = set
- ruleHits = Dictionary(set.rules.map { ($0.id, $0.hits) }, uniquingKeysWith: { a, _ in a })
+ ruleHits = Dictionary(set.rules.map { rule in
+ let published = rule.hits
+ let runtime = ruleHits[rule.id] ?? published
+ return (rule.id, set.resetHitCounts.contains(rule.id) ? published : max(published, runtime))
+ }, uniquingKeysWith: { a, _ in a })
return set.rules.filter(\.enabled).count
}
+ /// Atomically spends one bounded rule for a connection the local proxy is about to decide. Unbounded rules need
+ /// no claim. Nil means the app and extension no longer share this rule generation and the proxy must fail open.
+ func claimRule(_ claim: RuleClaim) -> Bool? {
+ lock.lock(); defer { lock.unlock() }
+ guard ruleSet.accepts(claim),
+ let rule = ruleSet.rules.first(where: { $0.id == claim.ruleID }),
+ rule.maxHits > 0 else {
+ return nil
+ }
+ let hits = ruleHits[claim.ruleID] ?? rule.hits
+ guard hits < rule.maxHits else { return false }
+ ruleHits[claim.ruleID] = hits + 1
+ return true
+ }
+
func apply(_ snapshot: RulePackSnapshot) throws -> RulePackApplyResult {
let matcher = try RulePackMatcher(snapshot: snapshot)
lock.lock()
@@ -176,25 +195,35 @@ final class BlockEnforcer: @unchecked Sendable {
/// is away, for the same reason an allowlist isn't: a refusal nobody can see, and nobody can undo, is worse
/// than the connection it stopped.
private func ruleDecision(_ facts: FlowFacts, process: ProcessInfoRecord, agent: Agent?) -> RuleDecision {
- lock.lock()
- let set = ruleSet
- let hits = ruleHits
- lock.unlock()
// The pause stands everything down, allowlists included: it is the one global escape hatch, and an escape
// hatch that only half works is worse than none. `.undecided` rather than `.allow`, so the flow is judged
// again when the pause ends rather than being waved through for its whole life.
- if let until = set.pausedUntil, Date() < until { return RuleDecision(verdict: .undecided, rule: nil) }
- guard !set.rules.isEmpty, inForce() else { return RuleDecision(verdict: .allow, rule: nil) }
+ guard inForce() else { return RuleDecision(verdict: .allow, rule: nil) }
if BlockRules.isExempt(facts) { return RuleDecision(verdict: .allow, rule: nil) }
+ lock.lock()
+ let set = ruleSet
+ if let until = set.pausedUntil, Date() < until {
+ lock.unlock()
+ return RuleDecision(verdict: .undecided, rule: nil)
+ }
+ guard !set.rules.isEmpty else {
+ lock.unlock()
+ return RuleDecision(verdict: .allow, rule: nil)
+ }
let rules = set.rules.map { rule -> Rule in
var copy = rule
- copy.hits = hits[rule.id] ?? rule.hits
+ copy.hits = ruleHits[rule.id] ?? rule.hits
return copy
}
let decision = RuleBook.decide(facts, rules: rules, session: set.session, pausedUntil: set.pausedUntil)
- guard let rule = decision.rule else { return decision }
- lock.lock(); ruleHits[rule.id] = (ruleHits[rule.id] ?? 0) + 1; lock.unlock()
- let event = RuleEvent(at: Int64(Date().timeIntervalSince1970), ruleID: rule.id, action: rule.action,
+ guard let rule = decision.rule else {
+ lock.unlock()
+ return decision
+ }
+ ruleHits[rule.id] = (ruleHits[rule.id] ?? 0) + 1
+ lock.unlock()
+ let event = RuleEvent(at: Int64(Date().timeIntervalSince1970), ruleID: rule.id,
+ ruleSession: set.session, ruleGeneration: set.generation, action: rule.action,
engine: .flow, agentKey: facts.agentKey, agentName: agent?.name ?? process.name,
appName: process.name, bundleID: process.bundleID, host: facts.host, ip: facts.ip,
port: facts.port)
diff --git a/FlowlightExtension/IPCServer.swift b/FlowlightExtension/IPCServer.swift
index acf6b54d..944d24f8 100644
--- a/FlowlightExtension/IPCServer.swift
+++ b/FlowlightExtension/IPCServer.swift
@@ -137,22 +137,43 @@ final class IPCServer: NSObject, NSXPCListenerDelegate, FlowlightProviderXPC, @u
self.flushBlocksLocked()
self.flushDecisionsLocked()
self.flushPackEventsLocked()
+ // Registration is the synchronization barrier: the app may publish rules only after this reset is done.
+ reply(true, Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "")
}
- reply(true, Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "")
}
func setEnforcement(payload: Data, withReply reply: @escaping (Int) -> Void) {
- let policies = payload.isEmpty ? [] : (BlockCoding.decode([AgentPolicy].self, from: payload) ?? [])
- let count = BlockEnforcer.shared.apply(policies)
- extensionLog.info("Enforcing \(count, privacy: .public) allowlists")
- reply(count)
+ guard let connection = NSXPCConnection.current() else { reply(-1); return }
+ queue.async {
+ guard self.client === connection else { reply(-1); return }
+ let policies = payload.isEmpty ? [] : (BlockCoding.decode([AgentPolicy].self, from: payload) ?? [])
+ let count = BlockEnforcer.shared.apply(policies)
+ // Rules are synchronized first. Once both ordinary policy sources are installed, enforcement can safely
+ // run; managed packs remain independently cleared until their own commit or clear succeeds.
+ BlockEnforcer.shared.appConnectionChanged(connected: true)
+ extensionLog.info("Enforcing \(count, privacy: .public) allowlists")
+ reply(count)
+ }
}
func setRules(payload: Data, withReply reply: @escaping (Int) -> Void) {
- let set = payload.isEmpty ? RuleSet() : (BlockCoding.decode(RuleSet.self, from: payload) ?? RuleSet())
- let count = BlockEnforcer.shared.apply(set)
- extensionLog.info("Carrying out \(count, privacy: .public) rules")
- reply(count)
+ guard let connection = NSXPCConnection.current() else { reply(-1); return }
+ queue.async {
+ guard self.client === connection else { reply(-1); return }
+ let set = payload.isEmpty ? RuleSet() : (BlockCoding.decode(RuleSet.self, from: payload) ?? RuleSet())
+ let count = BlockEnforcer.shared.apply(set)
+ extensionLog.info("Carrying out \(count, privacy: .public) rules")
+ reply(count)
+ }
+ }
+
+ func claimRule(payload: Data, withReply reply: @escaping (Int) -> Void) {
+ guard let claim = BlockCoding.decode(RuleClaim.self, from: payload),
+ let claimed = BlockEnforcer.shared.claimRule(claim) else {
+ reply(-1)
+ return
+ }
+ reply(claimed ? 1 : 0)
}
func rulePackHello(appProtocol: Int, withReply reply: @escaping (Data) -> Void) {
@@ -290,6 +311,7 @@ final class IPCServer: NSObject, NSXPCListenerDelegate, FlowlightProviderXPC, @u
}
guard let batch = packEventBatch else { return }
let capturedClient = client
+ let capturedClientID = ObjectIdentifier(capturedClient)
let proxy = capturedClient.remoteObjectProxyWithErrorHandler { [weak self, weak capturedClient] error in
extensionLog.error("rulePackEvents() failed: \(error.localizedDescription, privacy: .public)")
self?.queue.async {
@@ -303,9 +325,10 @@ final class IPCServer: NSObject, NSXPCListenerDelegate, FlowlightProviderXPC, @u
return
}
packEventsInFlight = true
- proxy.rulePackEvents(payload: BlockCoding.encode(batch)) { [weak self, weak capturedClient] accepted in
+ proxy.rulePackEvents(payload: BlockCoding.encode(batch)) { [weak self] accepted in
self?.queue.async {
- guard let self, self.client === capturedClient, self.packEventBatch?.id == batch.id else { return }
+ guard let self, self.client.map(ObjectIdentifier.init) == capturedClientID,
+ self.packEventBatch?.id == batch.id else { return }
self.packEventsInFlight = false
if accepted {
self.packEventBatch = nil
diff --git a/FlowlightExtension/Info.plist b/FlowlightExtension/Info.plist
index 6bf31fca..8d1cc07a 100644
--- a/FlowlightExtension/Info.plist
+++ b/FlowlightExtension/Info.plist
@@ -25,7 +25,7 @@
CFBundleShortVersionString0.15.4CFBundleVersion
- 24
+ 25LSMinimumSystemVersion$(MACOSX_DEPLOYMENT_TARGET)FLAppGroupIdentifier
diff --git a/FlowlightTests/AppLifecycleTests.swift b/FlowlightTests/AppLifecycleTests.swift
new file mode 100644
index 00000000..668230b0
--- /dev/null
+++ b/FlowlightTests/AppLifecycleTests.swift
@@ -0,0 +1,40 @@
+import AppKit
+import XCTest
+@testable import Flowlight
+
+@MainActor
+final class AppLifecycleTests: XCTestCase {
+ override func tearDown() {
+ AppDelegate.resetStartupForTests()
+ super.tearDown()
+ }
+
+ func testStartupRunsOnlyOnceAcrossRepeatedInstallations() {
+ AppDelegate.resetStartupForTests(didFinishLaunching: true)
+ var starts = 0
+
+ AppDelegate.installStartup { starts += 1 }
+ AppDelegate.installStartup { starts += 100 }
+
+ XCTAssertEqual(starts, 1)
+ }
+
+ func testLatestPrelaunchOwnerStartsOnceWhenApplicationFinishesLaunching() {
+ AppDelegate.resetStartupForTests()
+ let delegate = AppDelegate()
+ var starts = 0
+
+ AppDelegate.installStartup { starts += 100 }
+ AppDelegate.installStartup { starts += 1 }
+ XCTAssertEqual(starts, 0)
+
+ delegate.applicationDidFinishLaunching(
+ Notification(name: NSApplication.didFinishLaunchingNotification)
+ )
+ delegate.applicationDidFinishLaunching(
+ Notification(name: NSApplication.didFinishLaunchingNotification)
+ )
+
+ XCTAssertEqual(starts, 1)
+ }
+}
diff --git a/FlowlightTests/ExtensionRebootTests.swift b/FlowlightTests/ExtensionRebootTests.swift
index 3e3787d4..f8ae039b 100644
--- a/FlowlightTests/ExtensionRebootTests.swift
+++ b/FlowlightTests/ExtensionRebootTests.swift
@@ -56,6 +56,8 @@ final class ExtensionRebootTests: XCTestCase {
/// on a loop, so the read has to actually find it in the bundle that hosts these tests.
func testTheExpectedVersionIsReadFromTheEmbeddedExtension() {
XCTAssertFalse(ExtensionManager.expectedExtensionVersion.isEmpty)
+ XCTAssertTrue(ExtensionManager.expectedExtensionVersion.contains("/"),
+ "the repair identity must include CFBundleVersion when extension code changes")
}
/// The held-slot state, read from what `systemextensionsctl list` actually prints — the reliable signal, since
diff --git a/FlowlightTests/ExtensionRecoveryTests.swift b/FlowlightTests/ExtensionRecoveryTests.swift
index c2080302..ec46dda3 100644
--- a/FlowlightTests/ExtensionRecoveryTests.swift
+++ b/FlowlightTests/ExtensionRecoveryTests.swift
@@ -159,11 +159,18 @@ final class ExtensionVersionTests: XCTestCase {
XCTAssertEqual(ExtensionVersion.compare("0.9.0", "0.10.0"), .orderedAscending)
}
- /// `systemextensionsctl list` prints the short version and the build together. Whatever reads it should get
- /// the same answer as the properties request, which only carries the short version.
- func testTheBuildSuffixIsIgnored() {
+ /// A legacy caller that knows only the short version remains compatible with the full identity.
+ func testAnUnknownBuildSuffixDoesNotCreateAFalseMismatch() {
XCTAssertEqual(ExtensionVersion.compare("0.5.0/8", "0.5.0"), .orderedSame)
}
+
+ /// The failure this release exposed: extension code changed without changing the short version. The bundle build
+ /// is the only fact that distinguishes the active old binary from the embedded new one.
+ func testDifferentBundleBuildsAreStale() {
+ XCTAssertEqual(ExtensionVersion.compare("0.15.4/24", "0.15.4/25"), .orderedAscending)
+ XCTAssertTrue(ExtensionVersion.isStale(installed: ["0.15.4/24"], appVersion: "0.15.4/25"))
+ XCTAssertFalse(ExtensionVersion.isStale(installed: ["0.15.4/25"], appVersion: "0.15.4/25"))
+ }
}
/// What the app does with the menu bar when it is meant to run in the background.
diff --git a/FlowlightTests/ExtensionSourceTests.swift b/FlowlightTests/ExtensionSourceTests.swift
index 26e7e389..95ee995f 100644
--- a/FlowlightTests/ExtensionSourceTests.swift
+++ b/FlowlightTests/ExtensionSourceTests.swift
@@ -39,4 +39,25 @@ final class ExtensionSourceTests: XCTestCase {
lock.lock(); defer { lock.unlock() }
XCTAssertEqual(count, 1)
}
+
+ func testRulePackPushGateAbsorbsDuplicateStartupPush() {
+ var gate = RulePackPushGate()
+
+ XCTAssertTrue(gate.begin(epoch: 2))
+ XCTAssertFalse(gate.begin(epoch: 2))
+ XCTAssertEqual(gate.finish(epoch: 2), false)
+ XCTAssertTrue(gate.begin(epoch: 2))
+ }
+
+ func testRulePackPushGateCoalescesANewerSnapshot() {
+ var gate = RulePackPushGate()
+
+ XCTAssertTrue(gate.begin(epoch: 2))
+ XCTAssertFalse(gate.begin(epoch: 3))
+ XCTAssertFalse(gate.begin(epoch: 4))
+ XCTAssertEqual(gate.finish(epoch: 2), true)
+ XCTAssertTrue(gate.begin(epoch: 4))
+ XCTAssertNil(gate.finish(epoch: 3))
+ XCTAssertEqual(gate.finish(epoch: 4), false)
+ }
}
diff --git a/FlowlightTests/InspectionTests.swift b/FlowlightTests/InspectionTests.swift
index 64247009..2fdcd0a3 100644
--- a/FlowlightTests/InspectionTests.swift
+++ b/FlowlightTests/InspectionTests.swift
@@ -234,6 +234,42 @@ final class LLMToolCallReaderTests: XCTestCase {
}
final class InspectionStorageTests: XCTestCase {
+ func testProxyOwnerLookupRetriesUntilIdentityAppears() {
+ let state = ProxyOwnerLookupTestState(ownerAppearsAt: 3)
+
+ ProxyOwnerLookup.resolve(
+ delays: ProxyOwnerLookup.retryDelays[...],
+ lookup: { state.lookup() },
+ schedule: { delay, work in
+ state.schedule(delay)
+ work()
+ },
+ completion: { state.resolve($0) }
+ )
+
+ XCTAssertEqual(state.lookupCount, 3)
+ XCTAssertEqual(state.scheduledDelays, [0.01, 0.025])
+ XCTAssertEqual(state.resolvedOwner.bundleID, "com.example.app")
+ }
+
+ func testProxyOwnerLookupFailsOpenAfterBoundedRetries() {
+ let state = ProxyOwnerLookupTestState(ownerAppearsAt: nil)
+
+ ProxyOwnerLookup.resolve(
+ delays: ProxyOwnerLookup.retryDelays[...],
+ lookup: { state.lookup() },
+ schedule: { delay, work in
+ state.schedule(delay)
+ work()
+ },
+ completion: { state.resolve($0) }
+ )
+
+ XCTAssertEqual(state.lookupCount, 4)
+ XCTAssertEqual(state.scheduledDelays, ProxyOwnerLookup.retryDelays)
+ XCTAssertTrue(state.resolvedOwner.bundleID.isEmpty)
+ }
+
func testNeverInspectMatchingAndPAC() {
XCTAssertTrue(InspectionController.matches(host: "gateway.icloud.com", patterns: ["icloud.com"]))
XCTAssertTrue(InspectionController.matches(host: "apple.com", patterns: ["*.apple.com"]))
@@ -244,6 +280,34 @@ final class InspectionStorageTests: XCTestCase {
XCTAssertFalse(pac.contains("alert(1)\""))
}
+ func testProxyFactsPutNumericDestinationsInTheAddressField() {
+ let owner = InspectionRecorder.Owner(bundleID: "com.example.app")
+ let v4 = InspectionController.proxyFlowFacts(host: "203.0.113.9", port: 443, owner: owner)
+ let v6 = InspectionController.proxyFlowFacts(host: "2001:0db8::1", port: 8443, owner: owner)
+
+ XCTAssertEqual(v4.host, "")
+ XCTAssertEqual(v4.ip, "203.0.113.9")
+ XCTAssertTrue(Rule(action: .block, destination: "203.0.113.9").matches(v4))
+ XCTAssertTrue(Rule(action: .block, destination: "203.0.113.0/24").matches(v4))
+ XCTAssertTrue(AgentPolicy.matches("203.0.113.0/24", host: v4.host, ip: v4.ip),
+ "request-level proxy rules use the same destination split")
+ XCTAssertEqual(v6.host, "")
+ XCTAssertEqual(v6.ip, "2001:db8::1")
+ XCTAssertTrue(Rule(action: .block, destination: "2001:db8::/32").matches(v6))
+ }
+
+ func testProxyFactsKeepNamesAndExemptLocalAddresses() {
+ let owner = InspectionRecorder.Owner(bundleID: "com.example.app")
+ let named = InspectionController.proxyFlowFacts(host: "API.Example.com", port: 443, owner: owner)
+ let loopback = InspectionController.proxyFlowFacts(host: "127.0.0.1", port: 8080, owner: owner)
+ let privateAddress = InspectionController.proxyFlowFacts(host: "192.168.1.4", port: 8080, owner: owner)
+
+ XCTAssertEqual(named.host, "API.Example.com")
+ XCTAssertEqual(named.ip, "")
+ XCTAssertTrue(BlockRules.isExempt(loopback))
+ XCTAssertTrue(BlockRules.isExempt(privateAddress))
+ }
+
func testExchangeRoundTrip() throws {
let url = FileManager.default.temporaryDirectory.appendingPathComponent("x-\(UUID().uuidString).sqlite")
defer { try? FileManager.default.removeItem(at: url) }
@@ -270,6 +334,51 @@ final class InspectionStorageTests: XCTestCase {
}
}
+private final class ProxyOwnerLookupTestState: @unchecked Sendable {
+ private let lock = NSLock()
+ private let ownerAppearsAt: Int?
+ private var lookups = 0
+ private var delays: [TimeInterval] = []
+ private var owner = InspectionRecorder.Owner(bundleID: "sentinel")
+
+ init(ownerAppearsAt: Int?) {
+ self.ownerAppearsAt = ownerAppearsAt
+ }
+
+ func lookup() -> InspectionRecorder.Owner {
+ lock.lock(); defer { lock.unlock() }
+ lookups += 1
+ return lookups == ownerAppearsAt
+ ? InspectionRecorder.Owner(bundleID: "com.example.app")
+ : InspectionRecorder.Owner()
+ }
+
+ func schedule(_ delay: TimeInterval) {
+ lock.lock(); defer { lock.unlock() }
+ delays.append(delay)
+ }
+
+ func resolve(_ owner: InspectionRecorder.Owner) {
+ lock.lock(); defer { lock.unlock() }
+ self.owner = owner
+ }
+
+ var lookupCount: Int {
+ lock.lock(); defer { lock.unlock() }
+ return lookups
+ }
+
+ var scheduledDelays: [TimeInterval] {
+ lock.lock(); defer { lock.unlock() }
+ return delays
+ }
+
+ var resolvedOwner: InspectionRecorder.Owner {
+ lock.lock(); defer { lock.unlock() }
+ return owner
+ }
+}
+
final class ToolCallLinksTests: XCTestCase {
private func exchange(_ id: Int64, at: Double, app: String, bundle: String, host: String, calls: [ToolCall] = []) -> HTTPExchange {
HTTPExchange(id: id, started: Date(timeIntervalSince1970: at), duration: 0.5, scheme: "https", host: host, port: 443, method: "GET",
diff --git a/FlowlightTests/MockResponseTests.swift b/FlowlightTests/MockResponseTests.swift
index 38ec4e72..59152a32 100644
--- a/FlowlightTests/MockResponseTests.swift
+++ b/FlowlightTests/MockResponseTests.swift
@@ -323,11 +323,13 @@ final class MockRuleStorageTests: XCTestCase {
/// the decision, this covers the wiring that acts on it.
final class MockProxyTests: XCTestCase {
/// A stand-in server that answers "real" to anything that reaches it, and says whether anything did.
- private func startUpstream(reached: @escaping () -> Void) throws -> (NWListener, UInt16) {
+ private func startUpstream(accepted: @escaping () -> Void = {},
+ reached: @escaping () -> Void) throws -> (NWListener, UInt16) {
// Not the main queue: the test blocks its own thread waiting for curl, which would deadlock the stub.
let queue = DispatchQueue(label: "test.upstream")
let upstream = try NWListener(using: .tcp)
upstream.newConnectionHandler = { conn in
+ accepted()
conn.start(queue: queue)
// "Reached" means request bytes arrived: the proxy opens the upstream socket either way.
conn.receive(minimumIncompleteLength: 1, maximumLength: 65536) { data, _, _, _ in
@@ -354,6 +356,16 @@ final class MockProxyTests: XCTestCase {
return (proxy, try XCTUnwrap(proxy.port))
}
+ private func startDeniedProxy() throws -> (InspectionProxy, UInt16) {
+ let proxy = InspectionProxy()
+ proxy.authorizeConnection = { _, _, _, answer in answer(false) }
+ let ready = expectation(description: "proxy ready")
+ proxy.onStateChange = { _ in if proxy.port != nil { ready.fulfill() } }
+ proxy.start(port: 0)
+ wait(for: [ready], timeout: 10)
+ return (proxy, try XCTUnwrap(proxy.port))
+ }
+
/// curl, because the proxy refuses to proxy its own process — as it should, or it would loop.
private func curl(_ url: String, through proxyPort: UInt16) -> (status: String, body: String, seconds: Double) {
let task = Process()
@@ -415,4 +427,47 @@ final class MockProxyTests: XCTestCase {
XCTAssertTrue(reached)
XCTAssertNil(recorded.first?.mockRule)
}
+
+ func testDeniedPlainHTTPConnectionNeverOpensUpstream() throws {
+ let accepted = expectation(description: "upstream accepted")
+ accepted.isInverted = true
+ let (upstream, upstreamPort) = try startUpstream(accepted: { accepted.fulfill() }) {}
+ defer { upstream.cancel() }
+ let (proxy, proxyPort) = try startDeniedProxy()
+ defer { proxy.stop() }
+
+ let result = curl("http://127.0.0.1:\(upstreamPort)/blocked", through: proxyPort)
+
+ XCTAssertEqual(result.status, "403")
+ wait(for: [accepted], timeout: 0.5)
+ }
+
+ func testDeniedConnectIsRejectedBeforeTunnelOpens() throws {
+ let accepted = expectation(description: "CONNECT upstream accepted")
+ accepted.isInverted = true
+ let (upstream, upstreamPort) = try startUpstream(accepted: { accepted.fulfill() }) {}
+ defer { upstream.cancel() }
+ let (proxy, proxyPort) = try startDeniedProxy()
+ defer { proxy.stop() }
+
+ // Use another process: the proxy deliberately rejects its own process before policy authorization to avoid loops.
+ let client = Process()
+ client.executableURL = URL(fileURLWithPath: "/usr/bin/nc")
+ client.arguments = ["127.0.0.1", String(proxyPort)]
+ let input = Pipe()
+ let output = Pipe()
+ client.standardInput = input
+ client.standardOutput = output
+ client.standardError = FileHandle.nullDevice
+ try client.run()
+ input.fileHandleForWriting.write(Data(
+ "CONNECT 127.0.0.1:\(upstreamPort) HTTP/1.1\r\nHost: 127.0.0.1:\(upstreamPort)\r\n\r\n".utf8
+ ))
+ try input.fileHandleForWriting.close()
+ let bytes = output.fileHandleForReading.readDataToEndOfFile()
+ client.waitUntilExit()
+
+ XCTAssertTrue(String(decoding: bytes, as: UTF8.self).hasPrefix("HTTP/1.1 403 Forbidden"))
+ wait(for: [accepted], timeout: 0.5)
+ }
}
diff --git a/FlowlightTests/RulePackEventDeliveryTests.swift b/FlowlightTests/RulePackEventDeliveryTests.swift
new file mode 100644
index 00000000..7dbcdc10
--- /dev/null
+++ b/FlowlightTests/RulePackEventDeliveryTests.swift
@@ -0,0 +1,167 @@
+import XCTest
+@testable import Flowlight
+
+final class RulePackEventDeliveryTests: XCTestCase {
+ func testRetryReusesDeliveryIdentityAndCommitsOnce() {
+ let batches = LockedValue<[RulePackEventBatch]>([])
+ let delivered = expectation(description: "retried batch committed")
+ let delivery = RulePackEventDelivery(retryDelay: 0.01, attemptTimeout: 1) { batch, claim, completion in
+ XCTAssertTrue(claim())
+ let attempt = batches.withValue {
+ $0.append(batch)
+ return $0.count
+ }
+ completion(attempt == 2)
+ }
+
+ delivery.enqueue(event()) { committed in
+ XCTAssertTrue(committed)
+ delivered.fulfill()
+ }
+ wait(for: [delivered], timeout: 2)
+
+ let captured = batches.value
+ XCTAssertEqual(captured.count, 2)
+ XCTAssertEqual(captured[0].deliverySession, captured[1].deliverySession)
+ XCTAssertEqual(captured[0].sequence, captured[1].sequence)
+ XCTAssertEqual(captured[0].events, captured[1].events)
+ }
+
+ func testTwoFailedAttemptsFailOpen() {
+ let attempts = LockedValue(0)
+ let allowed = expectation(description: "refusal abandoned")
+ let delivery = RulePackEventDelivery(retryDelay: 0.01, attemptTimeout: 1) { _, claim, completion in
+ XCTAssertTrue(claim())
+ attempts.withValue { $0 += 1 }
+ completion(false)
+ }
+
+ delivery.enqueue(event()) { committed in
+ XCTAssertFalse(committed)
+ allowed.fulfill()
+ }
+ wait(for: [allowed], timeout: 2)
+ XCTAssertEqual(attempts.value, 2)
+ }
+
+ func testMissingAcknowledgementsTimeOutAndFailOpen() {
+ let attempts = LockedValue(0)
+ let allowed = expectation(description: "timed out refusal abandoned")
+ let delivery = RulePackEventDelivery(retryDelay: 0.01, attemptTimeout: 0.03) { _, _, _ in
+ attempts.withValue { $0 += 1 }
+ }
+
+ delivery.enqueue(event()) { committed in
+ XCTAssertFalse(committed)
+ allowed.fulfill()
+ }
+ wait(for: [allowed], timeout: 1)
+ XCTAssertEqual(attempts.value, 2)
+ }
+
+ func testConcurrentRefusalsHaveIndependentDeadlines() {
+ let first = expectation(description: "first failed open")
+ let second = expectation(description: "second failed open")
+ let started = Date()
+ let delivery = RulePackEventDelivery(retryDelay: 0.01, attemptTimeout: 0.03) { _, _, _ in }
+
+ delivery.enqueue(event()) { committed in
+ XCTAssertFalse(committed)
+ XCTAssertLessThan(Date().timeIntervalSince(started), 0.2)
+ first.fulfill()
+ }
+ delivery.enqueue(event()) { committed in
+ XCTAssertFalse(committed)
+ XCTAssertLessThan(Date().timeIntervalSince(started), 0.2)
+ second.fulfill()
+ }
+
+ wait(for: [first, second], timeout: 1)
+ }
+
+ func testTimedOutAttemptCannotLaterClaimADurableWrite() {
+ let claims = LockedValue<[RulePackEventDelivery.Claim]>([])
+ let callbacks = LockedValue<[@Sendable (Bool) -> Void]>([])
+ let allowed = expectation(description: "timed out refusal abandoned")
+ let delivery = RulePackEventDelivery(retryDelay: 0.01, attemptTimeout: 0.03) { _, claim, completion in
+ claims.withValue { $0.append(claim) }
+ callbacks.withValue { $0.append(completion) }
+ }
+
+ delivery.enqueue(event()) { committed in
+ XCTAssertFalse(committed)
+ allowed.fulfill()
+ }
+ wait(for: [allowed], timeout: 1)
+
+ let lateClaims = claims.value
+ let lateCallbacks = callbacks.value
+ XCTAssertEqual(lateClaims.count, 2)
+ XCTAssertTrue(lateClaims.allSatisfy { !$0() })
+ lateCallbacks.forEach { $0(true) }
+ }
+
+ func testInvalidateFailsAuthorizationOpenAndCancelsUnclaimedWrite() {
+ let sinkStarted = expectation(description: "sink started")
+ let authorization = expectation(description: "authorization failed open")
+ let invalidated = expectation(description: "delivery invalidated")
+ let heldClaim = LockedValue(nil)
+ let delivery = RulePackEventDelivery(retryDelay: 0.01, attemptTimeout: 1) { _, claim, _ in
+ heldClaim.withValue { $0 = claim }
+ sinkStarted.fulfill()
+ }
+
+ delivery.enqueue(event()) { committed in
+ XCTAssertFalse(committed)
+ authorization.fulfill()
+ }
+ wait(for: [sinkStarted], timeout: 1)
+ delivery.invalidate { invalidated.fulfill() }
+ wait(for: [authorization, invalidated], timeout: 1)
+
+ XCTAssertFalse(heldClaim.value?() ?? true)
+ }
+
+ private func event() -> RulePackEvent {
+ RulePackEvent(
+ bucket: 1_700_000_000,
+ packID: "official.ads",
+ packVersion: "1",
+ entryID: 7,
+ category: .advertising,
+ action: .block,
+ count: 1,
+ firstAt: 1_700_000_001,
+ lastAt: 1_700_000_001,
+ bundleID: "com.google.Chrome",
+ appName: "Google Chrome",
+ agentKey: "com.google.Chrome",
+ agentName: "Google Chrome",
+ host: "ads.example",
+ matchedHost: "ads.example",
+ port: 443
+ )
+ }
+}
+
+private final class LockedValue: @unchecked Sendable {
+ private let lock = NSLock()
+ private var stored: Value
+
+ init(_ value: Value) {
+ stored = value
+ }
+
+ var value: Value {
+ lock.lock()
+ defer { lock.unlock() }
+ return stored
+ }
+
+ @discardableResult
+ func withValue(_ body: (inout Value) -> Result) -> Result {
+ lock.lock()
+ defer { lock.unlock() }
+ return body(&stored)
+ }
+}
diff --git a/FlowlightTests/RulePackTests.swift b/FlowlightTests/RulePackTests.swift
index b8524eb2..67882f3c 100644
--- a/FlowlightTests/RulePackTests.swift
+++ b/FlowlightTests/RulePackTests.swift
@@ -63,6 +63,27 @@ final class RulePackTests: XCTestCase {
XCTAssertEqual(matches.map(\.packID), [second.id])
}
+ func testProxyMatcherOnlyPublishesTheExtensionCommittedDigest() {
+ let definition = pack(entries: [
+ RulePackEntry(id: 1, host: "ads.example", category: .advertising),
+ ])
+ let value = snapshot(definition)
+ let live = LiveRulePacks()
+
+ XCTAssertNil(live.match(host: "ads.example", bundleID: "com.example.app"))
+ live.commit(snapshot: value, activeDigest: "different")
+ XCTAssertNil(live.match(host: "ads.example", bundleID: "com.example.app"))
+ XCTAssertEqual(live.activeDigest, "")
+
+ live.commit(snapshot: value, activeDigest: value.digest)
+ XCTAssertEqual(live.match(host: "cdn.ads.example", bundleID: "com.example.app")?.packID, definition.id)
+ XCTAssertEqual(live.activeDigest, value.digest)
+
+ live.replace(snapshot: nil)
+ XCTAssertNil(live.match(host: "ads.example", bundleID: "com.example.app"))
+ XCTAssertEqual(live.activeDigest, "")
+ }
+
func testValidatorRejectsTamperDuplicateAndProtocolMismatch() throws {
let valid = pack(entries: [RulePackEntry(id: 1, host: "ads.example", category: .advertising)])
XCTAssertNoThrow(try RulePackValidator.validate([valid]))
@@ -211,6 +232,18 @@ final class RulePackTests: XCTestCase {
XCTAssertTrue(try reopened.loadRulePackOverrides().isEmpty)
}
+ func testAppLocalPackBatchesDoNotCreatePermanentWatermarks() throws {
+ let url = FileManager.default.temporaryDirectory
+ .appendingPathComponent("flowlight-pack-local-watermark-\(UUID().uuidString).sqlite")
+ let db = try TrafficDatabase(url: url)
+ var batch = activityBatch()
+ batch.requiresDurableWatermark = false
+
+ XCTAssertTrue(try db.recordRulePackEventBatch(batch))
+ XCTAssertTrue(try db.raw("SELECT delivery_session FROM rule_pack_event_watermarks") { $0.text(0) }.isEmpty)
+ XCTAssertEqual(try db.rulePackEvents(limit: 10).first?.count, 1)
+ }
+
func testStalePackEventMarkerRepairsThePhysicalUniqueKey() throws {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("flowlight-pack-stale-key-\(UUID().uuidString).sqlite")
@@ -534,7 +567,7 @@ final class RulePackTests: XCTestCase {
try await waitUntil { store.configuration(for: configuration.packID).enabled }
configuration = store.configuration(for: configuration.packID)
- store.clearActivity()
+ store.clearPublishedActivityAfterDurableDeletion()
XCTAssertTrue(store.events.isEmpty)
XCTAssertEqual(store.deliveryStats, RulePackDeliveryStats(droppedHits: 0, droppedKeys: 0))
@@ -542,6 +575,125 @@ final class RulePackTests: XCTestCase {
XCTAssertEqual(store.overrides, [exception])
}
+ @MainActor
+ func testDurableAcknowledgementDoesNotWaitForMainActorPublication() async throws {
+ let url = FileManager.default.temporaryDirectory
+ .appendingPathComponent("flowlight-pack-durable-ack-\(UUID().uuidString).sqlite")
+ let db = try TrafficDatabase(url: url)
+ let store = try await attachedStore(db: db)
+ let completed = DispatchSemaphore(value: 0)
+ let result = LockedBool()
+
+ store.record(activityBatch()) { committed in
+ result.set(committed)
+ completed.signal()
+ }
+
+ // This blocks MainActor publication. The durable acknowledgement must still come from the database queue.
+ XCTAssertEqual(completed.wait(timeout: .now() + 1), .success)
+ XCTAssertTrue(result.value)
+ XCTAssertEqual(try db.sync { try $0.rulePackEvents(limit: 10).count }, 1)
+ }
+
+ @MainActor
+ func testActivityClearRemovesAPreClearBatchQueuedAheadOfDeletion() async throws {
+ let url = FileManager.default.temporaryDirectory
+ .appendingPathComponent("flowlight-pack-clear-inflight-\(UUID().uuidString).sqlite")
+ let db = try TrafficDatabase(url: url)
+ let store = try await attachedStore(db: db)
+ let blockerStarted = expectation(description: "database queue blocked")
+ let batchRetired = expectation(description: "pre-clear batch retired")
+ let release = DispatchSemaphore(value: 0)
+ db.queue.async {
+ blockerStarted.fulfill()
+ release.wait()
+ }
+ await fulfillment(of: [blockerStarted], timeout: 1)
+
+ store.record(activityBatch()) { accepted in
+ XCTAssertTrue(accepted)
+ batchRetired.fulfill()
+ }
+ store.beginActivityClear()
+ let clear = Task {
+ try await db.asyncResult { try $0.clearAll() }
+ store.finishActivityClear(succeeded: true)
+ }
+ release.signal()
+
+ await fulfillment(of: [batchRetired], timeout: 2)
+ try await clear.value
+ XCTAssertTrue(store.events.isEmpty)
+ XCTAssertTrue(try db.rulePackEvents(limit: 10).isEmpty)
+ }
+
+ func testDeleteEverythingRemovesOrdinaryRuleEvents() throws {
+ let url = FileManager.default.temporaryDirectory
+ .appendingPathComponent("flowlight-rule-events-clear-\(UUID().uuidString).sqlite")
+ let db = try TrafficDatabase(url: url)
+ let rule = Rule(action: .block, destination: "example.com")
+ try db.recordRuleEvents([
+ RuleEvent(
+ at: 1_700_000_000,
+ ruleID: rule.id,
+ action: .block,
+ engine: .flow,
+ agentKey: "com.example.app",
+ agentName: "Example",
+ appName: "Example",
+ bundleID: "com.example.app",
+ host: "example.com",
+ ip: "",
+ port: 443
+ ),
+ ])
+ XCTAssertEqual(try db.ruleEvents(limit: 10).count, 1)
+
+ try db.clearAll()
+
+ XCTAssertTrue(try db.ruleEvents(limit: 10).isEmpty)
+ }
+
+ @MainActor
+ func testActivityArrivingDuringClearIsAcknowledgedWithoutWriting() async throws {
+ let url = FileManager.default.temporaryDirectory
+ .appendingPathComponent("flowlight-pack-clear-drop-\(UUID().uuidString).sqlite")
+ let db = try TrafficDatabase(url: url)
+ let store = try await attachedStore(db: db)
+ let dropped = expectation(description: "batch acknowledged during clear")
+
+ store.beginActivityClear()
+ store.record(activityBatch()) { accepted in
+ XCTAssertTrue(accepted)
+ dropped.fulfill()
+ }
+ await fulfillment(of: [dropped], timeout: 1)
+ store.finishActivityClear(succeeded: true)
+
+ XCTAssertTrue(store.events.isEmpty)
+ XCTAssertTrue(try db.rulePackEvents(limit: 10).isEmpty)
+ }
+
+ @MainActor
+ func testProxyActivityDuringClearFailsOpenWithoutWriting() async throws {
+ let url = FileManager.default.temporaryDirectory
+ .appendingPathComponent("flowlight-pack-clear-proxy-\(UUID().uuidString).sqlite")
+ let db = try TrafficDatabase(url: url)
+ let store = try await attachedStore(db: db)
+ let allowed = expectation(description: "proxy refusal failed open during clear")
+
+ store.beginActivityClear()
+ store.record(activityBatch(), acknowledgeDuringClear: false) { committed in
+ XCTAssertFalse(committed)
+ allowed.fulfill()
+ }
+ await fulfillment(of: [allowed], timeout: 1)
+ store.finishActivityClear(succeeded: true)
+
+ XCTAssertTrue(store.events.isEmpty)
+ XCTAssertTrue(try db.rulePackEvents(limit: 10).isEmpty)
+ }
+
@MainActor
func testClearingActivityRejectsCatalogInstallWithCapturedEvents() async throws {
let url = FileManager.default.temporaryDirectory
@@ -585,7 +737,7 @@ final class RulePackTests: XCTestCase {
)
try db.clearAll()
- store.clearActivity()
+ store.clearPublishedActivityAfterDurableDeletion()
XCTAssertEqual(
store.installCommittedCatalog(
@@ -1062,6 +1214,29 @@ final class RulePackTests: XCTestCase {
}
}
+ private func activityBatch() -> RulePackEventBatch {
+ RulePackEventBatch(events: [
+ RulePackEvent(
+ bucket: 1_700_000_000,
+ packID: "official.advertising",
+ packVersion: "1",
+ entryID: 1,
+ category: .advertising,
+ action: .block,
+ count: 1,
+ firstAt: 1_700_000_001,
+ lastAt: 1_700_000_001,
+ bundleID: "com.google.Chrome",
+ appName: "Google Chrome",
+ agentKey: "com.google.Chrome",
+ agentName: "Google Chrome",
+ host: "ads.example",
+ matchedHost: "ads.example",
+ port: 443
+ ),
+ ])
+ }
+
private func event(version: String, category: RulePackEntry.Category, matchedHost: String,
agentKey: String) -> RulePackEvent {
RulePackEvent(bucket: 1_700_000_000, packID: "official.ads", packVersion: version, entryID: 1,
@@ -1117,3 +1292,20 @@ final class RulePackTests: XCTestCase {
overrides: [])
}
}
+
+private final class LockedBool: @unchecked Sendable {
+ private let lock = NSLock()
+ private var stored = false
+
+ var value: Bool {
+ lock.lock()
+ defer { lock.unlock() }
+ return stored
+ }
+
+ func set(_ value: Bool) {
+ lock.lock()
+ stored = value
+ lock.unlock()
+ }
+}
diff --git a/FlowlightTests/RuleTests.swift b/FlowlightTests/RuleTests.swift
index b639c4a5..083d6e7b 100644
--- a/FlowlightTests/RuleTests.swift
+++ b/FlowlightTests/RuleTests.swift
@@ -3,6 +3,7 @@ import XCTest
/// The rule engine, which decides whether a connection happens. Everything here is plain data in and a verdict
/// out, so the whole of it can be checked without a filter, a proxy or a database.
+@MainActor
final class RuleTests: XCTestCase {
private let session = "this-run"
@@ -200,6 +201,447 @@ final class RuleTests: XCTestCase {
XCTAssertEqual(RuleBook.decide(facts(), rules: [block, allow], session: session).verdict, .block)
}
+ func testLiveProxyConsumesFiniteHitRulesExactlyOnce() {
+ var allow = Rule(action: .allow, app: "com.example.app", destination: "api.example.com")
+ allow.maxHits = 1
+ let block = Rule(action: .block, destination: "example.com")
+ let live = LiveRules()
+ live.replace([block, allow], pausedUntil: nil)
+
+ let first = live.flowDecision(facts())
+ XCTAssertEqual(first.decision.rule?.id, allow.id)
+ XCTAssertEqual(first.decision.verdict, .allow)
+ XCTAssertNil(first.externalClaim)
+
+ let second = live.flowDecision(facts())
+ XCTAssertEqual(second.decision.rule?.id, block.id)
+ XCTAssertEqual(second.decision.verdict, .block)
+ }
+
+ func testLiveProxyCanPreviewAndReconcileAnExtensionClaim() {
+ var allow = Rule(action: .allow, app: "com.example.app", destination: "api.example.com")
+ allow.maxHits = 1
+ let block = Rule(action: .block, destination: "example.com")
+ let live = LiveRules()
+ live.replace([block, allow], pausedUntil: nil, generation: 7)
+ _ = live.beginExternalClaimAuthority(
+ with: RuleSet(rules: [block, allow], generation: 7, session: RuleStore.session)
+ )
+
+ let preview = live.flowDecision(facts())
+ XCTAssertEqual(preview.decision.rule?.id, allow.id)
+ XCTAssertEqual(preview.externalClaim, RuleClaim(ruleID: allow.id, session: RuleStore.session, generation: 7))
+ live.markBoundedRuleClaimed(RuleClaim(ruleID: allow.id, session: RuleStore.session, generation: 7))
+ XCTAssertEqual(live.flowDecision(facts()).decision.rule?.id, block.id)
+
+ live.replace([block, allow], pausedUntil: nil, generation: 8)
+ live.markBoundedRuleSpent(RuleClaim(ruleID: allow.id, session: RuleStore.session, generation: 8))
+ XCTAssertEqual(live.flowDecision(facts()).decision.rule?.id, block.id)
+ }
+
+ func testOrdinaryPublicationCannotReopenAConsumedFiniteRule() {
+ var allow = Rule(action: .allow, app: "com.example.app", destination: "api.example.com")
+ allow.maxHits = 2
+ let block = Rule(action: .block, destination: "example.com")
+ let live = LiveRules()
+ live.replace([block, allow], pausedUntil: nil, generation: 1)
+
+ live.markBoundedRuleClaimed(RuleClaim(ruleID: allow.id, session: RuleStore.session, generation: 1))
+ live.markBoundedRuleClaimed(RuleClaim(ruleID: allow.id, session: RuleStore.session, generation: 1))
+ live.replace([block, allow], pausedUntil: nil, generation: 2)
+
+ XCTAssertEqual(live.flowDecision(facts()).decision.rule?.id, block.id)
+ }
+
+ func testExplicitResetMayLowerALiveFiniteRuleCount() {
+ var allow = Rule(action: .allow, app: "com.example.app", destination: "api.example.com")
+ allow.maxHits = 1
+ let block = Rule(action: .block, destination: "example.com")
+ let live = LiveRules()
+ live.replace([block, allow], pausedUntil: nil, generation: 1)
+ live.markBoundedRuleClaimed(RuleClaim(ruleID: allow.id, session: RuleStore.session, generation: 1))
+
+ live.replace([block, allow], pausedUntil: nil, generation: 2, resetHitCounts: [allow.id])
+
+ XCTAssertEqual(live.flowDecision(facts()).decision.rule?.id, allow.id)
+ }
+
+ func testStaleClaimRepliesDoNotUndoAnExplicitReset() {
+ var allow = Rule(action: .allow, app: "com.example.app", destination: "api.example.com")
+ allow.maxHits = 1
+ let live = LiveRules()
+ live.replace([allow], pausedUntil: nil, generation: 4)
+ _ = live.beginExternalClaimAuthority(
+ with: RuleSet(rules: [allow], generation: 4, session: RuleStore.session)
+ )
+ let stale = try! XCTUnwrap(live.flowDecision(facts()).externalClaim)
+
+ live.replace([allow], pausedUntil: nil, generation: 5, resetHitCounts: [allow.id])
+ live.markBoundedRuleClaimed(stale)
+ live.markBoundedRuleSpent(stale)
+
+ XCTAssertEqual(live.flowDecision(facts()).externalClaim,
+ RuleClaim(ruleID: allow.id, session: RuleStore.session, generation: 5))
+ }
+
+ func testExtensionClaimSurvivesAnUnrelatedRuleEdit() {
+ var allow = Rule(action: .allow, app: "com.example.app", destination: "api.example.com")
+ allow.maxHits = 1
+ let unrelated = Rule(action: .block, destination: "other.example.com")
+ let set = RuleSet(rules: [allow, unrelated], generation: 5, session: RuleStore.session,
+ ruleInvalidatedAt: [allow.id: 1, unrelated.id: 5])
+
+ XCTAssertTrue(
+ set.accepts(RuleClaim(ruleID: allow.id, session: RuleStore.session, generation: 1))
+ )
+ }
+
+ func testExtensionClaimDoesNotSurviveSameRuleInvalidation() {
+ var allow = Rule(action: .allow, app: "com.example.app", destination: "api.example.com")
+ allow.maxHits = 1
+ let set = RuleSet(rules: [allow], generation: 5, session: RuleStore.session,
+ ruleInvalidatedAt: [allow.id: 5])
+
+ XCTAssertFalse(
+ set.accepts(RuleClaim(ruleID: allow.id, session: RuleStore.session, generation: 4))
+ )
+ }
+
+ func testClaimReplySurvivesAnUnrelatedRuleEdit() {
+ var allow = Rule(action: .allow, app: "com.example.app", destination: "api.example.com")
+ allow.maxHits = 1
+ let unrelated = Rule(action: .block, destination: "other.example.com")
+ let live = LiveRules()
+ live.replace([allow, unrelated], pausedUntil: nil, generation: 4)
+ _ = live.beginExternalClaimAuthority(
+ with: RuleSet(rules: [allow, unrelated], generation: 4, session: RuleStore.session)
+ )
+ let claim = try! XCTUnwrap(live.flowDecision(facts()).externalClaim)
+
+ var edited = unrelated
+ edited.name = "Edited"
+ live.replace(
+ [allow, edited],
+ pausedUntil: nil,
+ generation: 5,
+ ruleInvalidatedAt: [unrelated.id: 5]
+ )
+ live.markBoundedRuleClaimed(claim)
+
+ XCTAssertNil(live.flowDecision(facts()).externalClaim)
+ }
+
+ @MainActor
+ func testStaleRuleEventDoesNotUndoAnExplicitReset() {
+ var allow = Rule(action: .allow, app: "com.example.app", destination: "api.example.com")
+ allow.maxHits = 1
+ let live = LiveRules()
+ let store = RuleStore()
+ store.save(allow)
+ let staleGeneration = store.ruleSet.generation
+ store.resetHits(allow)
+
+ store.record([
+ RuleEvent(
+ at: 1_700_000_000,
+ ruleID: allow.id,
+ ruleSession: RuleStore.session,
+ ruleGeneration: staleGeneration,
+ action: .allow,
+ engine: .flow,
+ agentKey: "com.example.app",
+ agentName: "Example",
+ appName: "Example",
+ bundleID: "com.example.app",
+ host: "api.example.com",
+ ip: "",
+ port: 443
+ ),
+ ])
+ live.replace(store.rules, pausedUntil: nil, generation: store.ruleSet.generation)
+
+ XCTAssertEqual(store.rules.first?.hits, 0)
+ XCTAssertEqual(live.flowDecision(facts()).decision.rule?.id, allow.id)
+ }
+
+ func testStaleRuleEventIsNotPersistedAfterReset() async throws {
+ let url = FileManager.default.temporaryDirectory
+ .appendingPathComponent("flowlight-rule-stale-event-\(UUID().uuidString).sqlite")
+ let db = try TrafficDatabase(url: url)
+ let store = RuleStore()
+ store.attach(db: db)
+ try await waitUntil { !store.rules.isEmpty || store.ruleSet.generation > 0 }
+ var allow = Rule(action: .allow, app: "com.example.app", destination: "api.example.com")
+ allow.maxHits = 1
+ store.save(allow)
+ let staleGeneration = store.ruleSet.generation
+ store.resetHits(allow)
+
+ store.record([
+ RuleEvent(
+ at: 1_700_000_000,
+ ruleID: allow.id,
+ ruleSession: RuleStore.session,
+ ruleGeneration: staleGeneration,
+ action: .allow,
+ engine: .flow,
+ agentKey: "com.example.app",
+ agentName: "Example",
+ appName: "Example",
+ bundleID: "com.example.app",
+ host: "api.example.com",
+ ip: "",
+ port: 443
+ ),
+ ])
+ try await Task.sleep(for: .milliseconds(50))
+
+ XCTAssertTrue(try db.sync { try $0.ruleEvents(limit: 10).isEmpty })
+ XCTAssertTrue(store.events.isEmpty)
+ }
+
+ @MainActor
+ func testRuleEventSurvivesAnUnrelatedRuleEdit() {
+ var allow = Rule(action: .allow, app: "com.example.app", destination: "api.example.com")
+ allow.maxHits = 1
+ var unrelated = Rule(action: .block, destination: "other.example.com")
+ let store = RuleStore()
+ store.save(allow)
+ let claimGeneration = store.ruleSet.generation
+ store.save(unrelated)
+ unrelated.name = "Edited"
+ store.save(unrelated)
+
+ store.record([
+ RuleEvent(
+ at: 1_700_000_000,
+ ruleID: allow.id,
+ ruleSession: RuleStore.session,
+ ruleGeneration: claimGeneration,
+ action: .allow,
+ engine: .flow,
+ agentKey: "com.example.app",
+ agentName: "Example",
+ appName: "Example",
+ bundleID: "com.example.app",
+ host: "api.example.com",
+ ip: "",
+ port: 443
+ ),
+ ])
+
+ XCTAssertEqual(store.rules.first(where: { $0.id == allow.id })?.hits, 1)
+ }
+
+ func testPublishedRuleEventsClearAfterDurableDeletion() async throws {
+ let url = FileManager.default.temporaryDirectory
+ .appendingPathComponent("flowlight-rule-events-published-clear-\(UUID().uuidString).sqlite")
+ let db = try TrafficDatabase(url: url)
+ let store = RuleStore()
+ store.attach(db: db)
+ try await waitUntil { !store.rules.isEmpty || store.ruleSet.generation > 0 }
+ let rule = Rule(action: .block, destination: "example.com")
+ store.save(rule)
+ store.record([
+ RuleEvent(
+ at: 1_700_000_000,
+ ruleID: rule.id,
+ action: .block,
+ engine: .flow,
+ agentKey: "com.example.app",
+ agentName: "Example",
+ appName: "Example",
+ bundleID: "com.example.app",
+ host: "example.com",
+ ip: "",
+ port: 443
+ ),
+ ])
+ try await waitUntil { !store.events.isEmpty }
+ XCTAssertEqual(store.events.count, 1)
+
+ try await db.asyncResult { try $0.clearAll() }
+ store.clearPublishedEventsAfterDurableDeletion()
+
+ XCTAssertTrue(store.events.isEmpty)
+ XCTAssertTrue(try db.sync { try $0.ruleEvents(limit: 10).isEmpty })
+ }
+
+ func testUncertainClaimFailsOpenWhenDurableStorageIsUnavailable() async {
+ var allow = Rule(action: .allow, app: "com.example.app", destination: "api.example.com")
+ allow.maxHits = 1
+ let store = RuleStore()
+ store.save(allow)
+ let claim = RuleClaim(
+ ruleID: allow.id,
+ session: RuleStore.session,
+ generation: store.ruleSet.generation
+ )
+ let completed = expectation(description: "retirement completed")
+ var saved: Bool?
+
+ store.retireBoundedClaim(claim) { result in
+ saved = result
+ completed.fulfill()
+ }
+ await fulfillment(of: [completed], timeout: 2)
+
+ XCTAssertEqual(saved, false)
+ XCTAssertEqual(store.rules.first(where: { $0.id == allow.id })?.hits, 1)
+ XCTAssertNil(store.live.flowDecision(facts()).externalClaim)
+ }
+
+ func testUncertainClaimIsPersistedSpentBeforeCompletion() async throws {
+ let url = FileManager.default.temporaryDirectory
+ .appendingPathComponent("flowlight-rule-uncertain-\(UUID().uuidString).sqlite")
+ let db = try TrafficDatabase(url: url)
+ let store = RuleStore()
+ store.attach(db: db)
+ try await waitUntil { !store.rules.isEmpty || store.ruleSet.generation > 0 }
+
+ var allow = Rule(action: .allow, app: "com.example.app", destination: "api.example.com")
+ allow.maxHits = 1
+ store.save(allow)
+ let claim = RuleClaim(
+ ruleID: allow.id,
+ session: RuleStore.session,
+ generation: store.ruleSet.generation
+ )
+ let persisted = expectation(description: "uncertain claim persisted")
+
+ store.retireBoundedClaim(claim) { saved in
+ XCTAssertTrue(saved)
+ persisted.fulfill()
+ }
+ await fulfillment(of: [persisted], timeout: 2)
+
+ XCTAssertEqual(store.rules.first(where: { $0.id == allow.id })?.hits, 1)
+ let loaded = try db.sync { try $0.loadRules() }
+ XCTAssertEqual(loaded.first(where: { $0.id == allow.id })?.hits, 1)
+ }
+
+ func testUncertainClaimSurvivesAnUnrelatedEditAndPersistsSpent() async throws {
+ let url = FileManager.default.temporaryDirectory
+ .appendingPathComponent("flowlight-rule-unrelated-\(UUID().uuidString).sqlite")
+ let db = try TrafficDatabase(url: url)
+ let store = RuleStore()
+ store.attach(db: db)
+ try await waitUntil { !store.rules.isEmpty || store.ruleSet.generation > 0 }
+
+ var allow = Rule(action: .allow, app: "com.example.app", destination: "api.example.com")
+ allow.maxHits = 1
+ var unrelated = Rule(action: .block, destination: "other.example.com")
+ store.save(allow)
+ let claim = RuleClaim(
+ ruleID: allow.id,
+ session: RuleStore.session,
+ generation: store.ruleSet.generation
+ )
+ store.save(unrelated)
+ unrelated.name = "Edited"
+ store.save(unrelated)
+ let persisted = expectation(description: "claim persisted across unrelated edit")
+
+ store.retireBoundedClaim(claim) { saved in
+ XCTAssertTrue(saved)
+ persisted.fulfill()
+ }
+ await fulfillment(of: [persisted], timeout: 2)
+
+ XCTAssertEqual(store.rules.first(where: { $0.id == allow.id })?.hits, 1)
+ let loaded = try db.sync { try $0.loadRules() }
+ XCTAssertEqual(loaded.first(where: { $0.id == allow.id })?.hits, 1)
+ }
+
+ func testExternalAuthorityReceivesLocallyConsumedHitsAtHandoff() {
+ var allow = Rule(action: .allow, app: "com.example.app", destination: "api.example.com")
+ allow.maxHits = 2
+ let live = LiveRules()
+ live.replace([allow], pausedUntil: nil, generation: 12)
+ XCTAssertEqual(live.flowDecision(facts()).decision.rule?.id, allow.id)
+
+ let transferred = live.beginExternalClaimAuthority(
+ with: RuleSet(rules: [allow], generation: 12, session: RuleStore.session)
+ )
+
+ XCTAssertEqual(transferred.rules.first?.hits, 1)
+ XCTAssertEqual(live.flowDecision(facts()).externalClaim,
+ RuleClaim(ruleID: allow.id, session: RuleStore.session, generation: 12))
+ }
+
+ func testExternalAuthorityRemainsStickyAcrossSourceFallback() {
+ var allow = Rule(action: .allow, app: "com.example.app", destination: "api.example.com")
+ allow.maxHits = 1
+ let live = LiveRules()
+ live.replace([allow], pausedUntil: nil, generation: 12)
+ _ = live.beginExternalClaimAuthority(
+ with: RuleSet(rules: [allow], generation: 12, session: RuleStore.session)
+ )
+
+ let afterFallback = live.flowDecision(facts())
+
+ XCTAssertEqual(afterFallback.externalClaim,
+ RuleClaim(ruleID: allow.id, session: RuleStore.session, generation: 12))
+ XCTAssertEqual(live.flowDecision(facts()).externalClaim,
+ afterFallback.externalClaim,
+ "fallback must fail open through unavailable claims, not resume a second counter")
+ }
+
+ func testRuleClaimRoundTripKeepsSessionIdentity() throws {
+ let claim = RuleClaim(
+ ruleID: UUID(),
+ session: "new-app-process",
+ generation: 1
+ )
+
+ let decoded = try JSONDecoder().decode(
+ RuleClaim.self,
+ from: JSONEncoder().encode(claim)
+ )
+
+ XCTAssertEqual(decoded, claim)
+ XCTAssertNotEqual(
+ decoded,
+ RuleClaim(
+ ruleID: claim.ruleID,
+ session: "old-app-process",
+ generation: claim.generation
+ )
+ )
+ }
+
+ func testExternalAuthorityOwnsFiniteRuleDuringSynchronization() {
+ var allow = Rule(action: .allow, app: "com.example.app", destination: "api.example.com")
+ allow.maxHits = 1
+ let live = LiveRules()
+ live.replace([allow], pausedUntil: nil, generation: 12)
+ _ = live.beginExternalClaimAuthority(
+ with: RuleSet(rules: [allow], generation: 12, session: RuleStore.session)
+ )
+
+ let first = live.flowDecision(facts())
+ let second = live.flowDecision(facts())
+
+ XCTAssertEqual(first.externalClaim, RuleClaim(ruleID: allow.id, session: RuleStore.session, generation: 12))
+ XCTAssertEqual(second.externalClaim, first.externalClaim)
+ XCTAssertEqual(second.decision.rule?.id, allow.id,
+ "the proxy must not spend independently while the extension owns the counter")
+ }
+
+ private func waitUntil(
+ timeout: TimeInterval = 2,
+ _ condition: @escaping @MainActor () -> Bool
+ ) async throws {
+ let deadline = Date().addingTimeInterval(timeout)
+ while !condition() {
+ if Date() >= deadline {
+ XCTFail("condition did not become true")
+ return
+ }
+ try await Task.sleep(for: .milliseconds(10))
+ }
+ }
+
// MARK: Which engine can carry a rule out
func testAPathMakesItTheProxysJob() {
diff --git a/FlowlightTests/TrafficMonitorRulePackStartupTests.swift b/FlowlightTests/TrafficMonitorRulePackStartupTests.swift
index 38f218ec..f60e322a 100644
--- a/FlowlightTests/TrafficMonitorRulePackStartupTests.swift
+++ b/FlowlightTests/TrafficMonitorRulePackStartupTests.swift
@@ -50,6 +50,30 @@ final class TrafficMonitorRulePackStartupTests: XCTestCase {
XCTAssertNotNil(monitor.rulePackCatalog.receipt)
}
+ @MainActor
+ func testSupersededExtensionCannotPublishALatePackCommit() async throws {
+ let oldExtension = ExtensionTrafficSource()
+ let replacement = RecordingTrafficSource()
+ var sourceCount = 0
+ let monitor = TrafficMonitor(
+ databaseURL: temporaryDatabaseURL("stale-extension-status"),
+ trafficSourceFactory: {
+ sourceCount += 1
+ return sourceCount == 1 ? oldExtension : replacement
+ }
+ )
+
+ monitor.start()
+ try await waitUntil { sourceCount == 1 && oldExtension.onRulePackStatus != nil }
+ monitor.reconnectSource()
+ try await waitUntil { replacement.started }
+
+ oldExtension.onRulePackStatus?(.init(status: .committed, activeDigest: "stale"))
+ try await Task.sleep(for: .milliseconds(20))
+
+ XCTAssertEqual(monitor.rulePacks.status, .off)
+ }
+
private func productionCatalog() throws -> VerifiedRulePackCatalog {
let url = try XCTUnwrap(Bundle.main.url(forResource: "official", withExtension: "json"))
return try RulePackCatalogVerifier.verify(
diff --git a/README.md b/README.md
index f29fc3da..3e4f01a1 100644
--- a/README.md
+++ b/README.md
@@ -128,8 +128,9 @@ An allowlist warns by default. Turn on **Refuse connections outside the list** f
connections as well, recording each one as an alert with **Allow … from Now On** to add it to the list in a click.
It needs the Network Extension — refusing a connection means being in its path, which the sampler can't do. Local
-traffic, DNS, essential Apple system services and Flowlight's own connections are never refused, enforcement lapses a few seconds
-after the app stops running, and HTTPS inspection doesn't combine with it.
+traffic, DNS, essential Apple system services and Flowlight's own connections are never refused, and enforcement lapses a few seconds
+after the app stops running. When HTTPS inspection proxies an app, destination rules and enabled managed packs are decided against the
+original app before Flowlight opens the origin connection; agent allowlists still apply only on the direct Network Extension path.
### Rules
An allowlist is one agent's idea of where it may go. A **rule** is the general version: it names a subject — an app,
diff --git a/Shared/IPCProtocol.swift b/Shared/IPCProtocol.swift
index 88c83fb7..a12486f0 100644
--- a/Shared/IPCProtocol.swift
+++ b/Shared/IPCProtocol.swift
@@ -20,6 +20,12 @@ import Foundation
/// like it the extension keeps none of it across a launch: a rule nobody is left to record is not enforced.
func setRules(payload: Data, withReply reply: @escaping (Int) -> Void)
+ /// Atomically spends one bounded flow rule on behalf of the inspection proxy. The extension is the single claim
+ /// authority while it is active, so a direct flow and a proxied flow cannot both consume "allow once". `payload`
+ /// is `BlockCoding.encode(RuleClaim)` and binds the claim to the exact app session and whole-list generation the
+ /// proxy previewed. The reply is 1 when claimed, 0 when already spent, and -1 when that publication no longer matches.
+ func claimRule(payload: Data, withReply reply: @escaping (Int) -> Void)
+
/// Negotiates the independently-versioned pack protocol before any bytes move.
func rulePackHello(appProtocol: Int, withReply reply: @escaping (Data) -> Void)
/// Starts one generation. Chunks are accepted only for this header and never affect the active matcher yet.
@@ -46,5 +52,5 @@ import Foundation
func ruleDecisions(payload: Data, reply: @escaping () -> Void)
/// Bounded minute rollups from managed packs. True is returned only after the batch is durably committed.
- func rulePackEvents(payload: Data, reply: @escaping (Bool) -> Void)
+ func rulePackEvents(payload: Data, reply: @escaping @Sendable (Bool) -> Void)
}
diff --git a/Shared/Rule.swift b/Shared/Rule.swift
index a41df111..da22d7cd 100644
--- a/Shared/Rule.swift
+++ b/Shared/Rule.swift
@@ -308,6 +308,9 @@ extension Rule {
struct RuleEvent: Codable, Sendable, Equatable {
var at: Int64
var ruleID: UUID
+ /// Present for generation-bound flow decisions. Buffered older/request events remain decodable without it.
+ var ruleSession: String? = nil
+ var ruleGeneration: UInt64? = nil
var action: Rule.Action
var engine: Rule.Engine
var agentKey: String
@@ -326,10 +329,31 @@ struct RuleEvent: Codable, Sendable, Equatable {
/// The enforcing half of the rule list, as the extension is told it: the rules themselves plus the one global
/// escape hatch. Sent whole on every change, like `AgentPolicy`, so the extension never has to reconcile a delta.
+struct RuleClaim: Codable, Sendable, Equatable {
+ var ruleID: UUID
+ /// Identifies the app process that published this generation; generations restart from zero on every launch.
+ var session: String
+ var generation: UInt64
+}
+
struct RuleSet: Codable, Sendable, Equatable {
var rules: [Rule] = []
+ /// Changes on every whole-list publication. Per-rule boundaries below distinguish relevant from unrelated changes.
+ var generation: UInt64 = 0
/// Nothing is refused before this moment. The escape hatch is what makes strict rules usable.
var pausedUntil: Date?
/// The token `.session` schedules are measured against.
var session = ""
+ /// The only hit counters allowed to move backwards in the extension. Ordinary event-driven publications can
+ /// arrive out of order with proxy claims and must preserve the newer runtime count.
+ var resetHitCounts: Set = []
+ /// The first generation describing each rule's current definition. A claim from an older whole-list generation
+ /// remains valid across unrelated edits, but never across an edit, reset, or delete of the claimed rule itself.
+ var ruleInvalidatedAt: [UUID: UInt64] = [:]
+
+ func accepts(_ claim: RuleClaim) -> Bool {
+ session == claim.session &&
+ claim.generation <= generation &&
+ claim.generation >= (ruleInvalidatedAt[claim.ruleID] ?? 0)
+ }
}
diff --git a/Shared/RulePack.swift b/Shared/RulePack.swift
index 10d25d62..b64000ae 100644
--- a/Shared/RulePack.swift
+++ b/Shared/RulePack.swift
@@ -185,6 +185,9 @@ struct RulePackEventBatch: Codable, Equatable, Sendable {
/// Stable for one extension process. Sequence is strictly increasing because only one batch is ever in flight.
var deliverySession = UUID()
var sequence: Int64 = 1
+ /// Extension deliveries survive uncertain cross-process acknowledgements. App-local proxy deliveries do not need
+ /// a permanent watermark: their database callback and authorization state live in the same process.
+ var requiresDurableWatermark = true
var events: [RulePackEvent]
var droppedHits: Int64 = 0
var droppedKeys: Int64 = 0
diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md
index d7ffabab..ba3be94a 100644
--- a/docs/DEVELOPMENT.md
+++ b/docs/DEVELOPMENT.md
@@ -157,8 +157,10 @@ sits in the path of a connection, and a switch that did nothing would be worse t
- Every refusal travels back as a `BlockEvent` and becomes a **Connection blocked** alert naming the agent, the
process, the destination and the fact that it didn't get there. That alert's context menu offers *Allow … from
Now On*, which adds the destination to the agent's list and pushes the change straight back to the filter.
-- A flow through the HTTPS inspection proxy leaves the Mac as Flowlight's own traffic, which is never refused, so
- blocking and inspection don't compose for the same agent.
+- A flow through the HTTPS inspection proxy leaves the Mac as exempt Flowlight traffic. Before opening that upstream
+ connection, the proxy attributes the original client and applies destination rules plus the exact managed-pack snapshot
+ acknowledged by the extension. Pack refusal waits for its durable activity record and fails open if that record cannot
+ commit. Agent allowlists remain extension-only and do not compose with the proxied leg.
- `BlockRules` is plain functions over plain data (like `AgentPolicy.matches`), so `BlockingTests` covers the whole
decision without a filter installed.
diff --git a/docs/de/docs/index.html b/docs/de/docs/index.html
index d7ad5a1b..f769514e 100644
--- a/docs/de/docs/index.html
+++ b/docs/de/docs/index.html
@@ -48,7 +48,7 @@
-
+
Zum Inhalt springen
@@ -456,6 +456,7 @@
Regeln
Verwaltete Werbe- und Tracker-Pakete
Regeln › Pakete enthält drei offizielle Hostnamenlisten: Werbung, Websiteübergreifendes Tracking und Datenschutz und Telemetrie. Sie sind von Anfang an sichtbar und standardmäßig alle deaktiviert. Zum Blockieren ist die Netzwerkerweiterung erforderlich; der nettop-Sampler kann nur beobachten.
Ausschließlich offizielle, signierte Updates. Flowlight prüft eine einzige festgelegte HTTPS-Quelle auf neuere, vom Herausgeber signierte Kataloge. Automatische Prüfungen sind standardmäßig aktiviert und können hier deaktiviert werden. Eine Signatur authentifiziert den Inhalt, aktiviert ihn aber nicht: Ein neues Paket bleibt deaktiviert, und bei geänderten Zielen wird ein aktiviertes Paket nicht mehr durchgesetzt, bis du die genaue neue Version geprüft und auf Sichern geklickt hast. Akzeptierte Inhalte können während eines Ausfalls als sichtbar veralteter letzter bekannter funktionsfähiger Stand erhalten bleiben. Dies sind Netzwerksperren auf Hostnamenebene: Sie können keine Lücken im DOM oder Layout, Browser-Overlays, skriptgesteuerten Pop-ups oder Fenster nativer Apps entfernen.
+
Wenn die HTTPS-Inspektion eine App durch Flowlights Proxy leitet, entscheidet er anhand der ursprünglichen App-Identität über gewöhnliche Zielregeln und aktivierte verwaltete Pakete, bevor eine Verbindung zum Ursprungsserver geöffnet wird. Bei angepinnten oder nie entschlüsselten CONNECT-Tunneln funktioniert das anhand des Hostnamens; Agenten-Erlaubnislisten bleiben auf den direkten Weg über die Netzwerkerweiterung beschränkt. Ist der Eigentümer oder Hostname unbekannt, wird die Verbindung im Zweifel zugelassen.
Erlaubnislisten
Wähle einen Agenten in KI-Agenten und sage über Erlaubnisliste in seinem Detail, wohin er sich verbinden darf. Trage Domains
@@ -475,8 +476,7 @@
Verbindungen verweigern
Datenverkehr. Ein blockierter Mac ist schlimmer als ein blockierter Agent, im Zweifel wird die Verbindung also erlaubt und aufgezeichnet.
Die Durchsetzung endet außerdem wenige Sekunden nachdem Flowlight nicht mehr läuft – eine Verweigerung, die niemand sehen kann, ist schlimmer als keine, und der Weg,
sie aufzuheben, liegt in der App.
-
HTTPS-Inspektion und Blockieren lassen sich nicht kombinieren: Datenverkehr, der über Flowlights Proxy läuft, geht als Flowlights eigener hinaus und wird
- nie verweigert.
+
Für eine App, die durch die HTTPS-Inspektion geleitet wird, entscheidet Flowlights Proxy gewöhnliche Zielregeln und aktivierte verwaltete Pakete anhand der ursprünglichen App-Identität, bevor er eine Verbindung zum Ursprungsserver öffnet. Bei angepinnten oder nie entschlüsselten CONNECT-Tunneln geschieht das anhand des Hostnamens. Agenten-Erlaubnislisten bleiben auf den direkten Weg über die Netzwerkerweiterung beschränkt; ist der Eigentümer oder Hostname unbekannt, wird die Verbindung im Zweifel zugelassen.
Schutzregeln für Agenten: ein Tool, einen Server oder eine Ressource blockieren
Welche Tools darf dieser Agent nutzen ist eine andere Frage als welche Hosts darf er erreichen, und hat darum ihren eigenen
diff --git a/docs/de/threat-model/index.html b/docs/de/threat-model/index.html
index dabbcf32..7604e14b 100644
--- a/docs/de/threat-model/index.html
+++ b/docs/de/threat-model/index.html
@@ -48,7 +48,7 @@
-
+
Eine Regel, die Verbindungen verweigert, wird von der Netzwerkerweiterung ausgeführt, im Weg der Verbindung, bevor sie zustande kommt. Eine
- Regel, die eine URL nennt, führt stattdessen der Inspektions-Proxy aus, der die Anfrage mit einem Status beantwortet, den die App lesen kann.
- Das sind die einzigen zwei Teile von Flowlight, die handeln statt zu berichten – der nettop-Sampler kann überhaupt nichts verweigern, auf einem
- Mac, der mit dem Sampler erfasst, wird also keine Regel durchgesetzt, und Flowlight sagt das, statt einen Schalter anzubieten, der nichts tut.
+
Eine Regel, die Verbindungen verweigert, wird normalerweise von der Netzwerkerweiterung ausgeführt, im Weg der Verbindung, bevor sie zustande kommt.
+ Bei Datenverkehr, der durch die HTTPS-Inspektion geleitet wird, wendet der lokale Proxy Zielregeln und aktivierte verwaltete Pakete auf den
+ ursprünglichen Client an, bevor er die Verbindung zum Ursprungsserver öffnet; eine Regel, die eine URL nennt, führt er nach der Entschlüsselung
+ ebenfalls dort aus und antwortet mit einem Status, den die App lesen kann. Das sind die einzigen zwei Teile von Flowlight, die handeln statt zu
+ berichten – der nettop-Sampler kann überhaupt nichts verweigern, auf einem Mac, der mit dem Sampler erfasst, wird also keine Regel durchgesetzt,
+ und Flowlight sagt das, statt einen Schalter anzubieten, der nichts tut.
Verhindern ist es trotzdem nicht. Ein Agent, der eine Shell ausführen kann, kann von Hand tun, was ein verweigertes Tool getan hätte. Eine
Verweigerung, die du mit einem Klick aufheben kannst, ist eine Verweigerung, durch die der nächste Versuch eines Agenten hindurchgehen kann.
Lokaler Datenverkehr, DNS, Apples eigene Dienste und Flowlights eigene Verbindungen werden nie verweigert, denn ein blockierter Mac ist ein
schlechteres Ergebnis als ein blockierter Agent. Und die Durchsetzung endet wenige Sekunden nachdem Flowlight nicht mehr läuft: Eine
Verweigerung, die niemand sehen kann, ist schlimmer als keine.
-
Inspektion und Verweigerung lassen sich nicht verbinden. Datenverkehr, der über Flowlights eigenen Proxy geleitet wird,
- verlässt den Mac als Flowlights Datenverkehr, und Flowlight verweigert sich selbst nie – eine App, deren Anfragen du liest, ist also eine App,
- deren Verbindungen Regeln nicht mehr stoppen. Die Inspektion einzuschalten, um mehr zu sehen, kostet dich die Durchsetzung, und auf dem
- Bildschirm stand das vorher nirgends.
+
Die Inspektion erhält die Verweigerung nach Hostnamen, mit einer Grenze. Datenverkehr, der durch Flowlights Proxy geleitet wird,
+ verlässt den Mac als ausgenommener Flowlight-Datenverkehr. Deshalb entscheidet der Proxy zuerst anhand der ursprünglichen App-Identität über
+ gewöhnliche Zielregeln und aktivierte verwaltete Pakete. Das funktioniert auch bei angepinnten oder nie entschlüsselten Tunneln, weil CONNECT den
+ Host nennt. Agenten-Erlaubnislisten bleiben eine Entscheidung der Netzwerkerweiterung und gelten nicht für diesen über den Proxy geleiteten Weg
+ zum Ursprungsserver; Datenverkehr ohne vertrauenswürdigen Eigentümer oder Hostnamen wird im Zweifel zugelassen, statt unsichtbar verweigert zu werden.
Was Flowlight selbst aufbewahrt
Metadaten zum Datenverkehr in einer lokalen SQLite-Datenbank, mit veröffentlichten Aufbewahrungsfristen. Bei eingeschalteter
diff --git a/docs/docs/index.html b/docs/docs/index.html
index 2a219d31..d9edb4e5 100644
--- a/docs/docs/index.html
+++ b/docs/docs/index.html
@@ -48,7 +48,7 @@
-
+
and press Save. If content changes while its editor is open, the stale Save is refused. An accepted catalog may remain available
as visibly stale last-known-good during an outage; rollback, equivocation, revoked trust, or corrupt cached bytes fail closed.
Each pack has its own scope. Apply it globally to every app, or only to selected applications. Blocking needs
- the Network Extension: the nettop sampler can record traffic, but it cannot sit in a connection's path and refuse it. Essential
- system traffic remains allowed — including the services that carry Apple accounts, push notifications, software updates,
- certificate checks and iCloud — even when a pack applies globally.
+ the Network Extension: the nettop sampler can record traffic, but it cannot sit in a connection's path and refuse it. When HTTPS
+ inspection routes an app through Flowlight's proxy, enabled packs and ordinary destination rules are decided against that original
+ app before any origin connection opens. This also covers pinned and never-decrypted tunnels by hostname; agent allowlists remain
+ limited to the direct Network Extension path. Essential system traffic remains allowed — including the services that carry Apple
+ accounts, push notifications, software updates, certificate checks and iCloud — even when a pack applies globally.
Preview before enforcing.Test against recent traffic replays the proposed pack and scope against up
to seven days of retained traffic. It reports only decisions that would actually change after ordinary rules, the global pause,
existing packs and agent policies are taken into account: the affected apps and destinations, connection counts, and bytes.
diff --git a/docs/es/docs/index.html b/docs/es/docs/index.html
index 39f39747..e0ba0f85 100644
--- a/docs/es/docs/index.html
+++ b/docs/es/docs/index.html
@@ -48,7 +48,7 @@
-
+
Reglas › Paquetes contiene tres listas oficiales de nombres de host: Publicidad, Seguimiento entre sitios y Privacidad y telemetría. Son visibles desde el principio y todas están desactivadas de forma predeterminada. El bloqueo requiere la Extensión de red; el muestreador nettop solo puede observar.
Solo actualizaciones oficiales y firmadas. Flowlight consulta una única fuente HTTPS fija para buscar catálogos más recientes firmados por el editor. Las comprobaciones automáticas están activadas de forma predeterminada y pueden desactivarse aquí. Una firma autentica el contenido, pero no lo activa: un paquete nuevo permanece desactivado y, si cambian los destinos, un paquete activado deja de aplicarse hasta que revises la nueva versión exacta y pulses Guardar. Durante una interrupción, el contenido aceptado puede conservarse como la última versión válida conocida, marcada visiblemente como obsoleta. Estos son bloqueos de red a nivel de nombre de host: no pueden eliminar huecos del DOM o del diseño, superposiciones del navegador, ventanas emergentes generadas por scripts ni ventanas de aplicaciones nativas.
+
Cuando la inspección HTTPS encamina una app por el proxy de Flowlight, este decide las reglas de destino normales y los paquetes gestionados activados usando la identidad original de la app antes de abrir ninguna conexión con el servidor de origen. En los túneles CONNECT con certificado fijado o que nunca se descifran, esto funciona por nombre de host; las listas de permitidos de agentes siguen limitadas al camino directo por la Extensión de red. Si no se conoce el propietario o el nombre de host, la conexión se permite.
Listas de permitidos
Selecciona un agente en Agentes de IA y usa Lista de permitidos en su detalle para indicar adónde puede conectar. Introduce dominios
@@ -470,8 +471,7 @@
Rechazar conexiones
de Flowlight. Un Mac bloqueado es peor resultado que un agente bloqueado, así que ante la duda la conexión se permite y se registra.
La aplicación de las reglas también caduca unos segundos después de que Flowlight deje de ejecutarse: un rechazo que nadie puede ver es peor que ninguno, y la forma
de deshacer uno está en la app.
-
La inspección HTTPS y el bloqueo no se combinan: el tráfico encaminado por el proxy de Flowlight sale como tráfico propio de Flowlight, que
- nunca se rechaza.
+
Para una app encaminada por la inspección HTTPS, el proxy de Flowlight decide las reglas de destino normales y los paquetes gestionados activados usando la identidad original de la app antes de abrir ninguna conexión con el servidor de origen. En los túneles CONNECT con certificado fijado o que nunca se descifran, lo hace por nombre de host. Las listas de permitidos de agentes siguen limitadas al camino directo por la Extensión de red; si no se conoce el propietario o el nombre de host, la conexión se permite.
Salvaguardas de agente: bloquear una herramienta, un servidor o un recurso
Qué herramientas puede usar este agente es una pregunta distinta de a qué hosts puede llegar, así que tiene su propia
diff --git a/docs/es/threat-model/index.html b/docs/es/threat-model/index.html
index a3b8e38e..fe901c52 100644
--- a/docs/es/threat-model/index.html
+++ b/docs/es/threat-model/index.html
@@ -48,7 +48,7 @@
-
+
De una regla que rechaza conexiones se encarga la extensión de red, en el camino, antes de que la conexión se haga. De una regla que nombra
- una URL se encarga en cambio el proxy de inspección, que responde a la petición con un estado que la app puede leer. Esas son las dos únicas
- partes de Flowlight que actúan en lugar de informar — el muestreador nettop no puede rechazar nada en absoluto, así que en un Mac que captura
- con el muestreador no se aplica ninguna regla, y Flowlight lo dice en lugar de ofrecer un interruptor que no hace nada.
+
De una regla que rechaza conexiones se encarga normalmente la extensión de red, en el camino, antes de que la conexión se haga. Para el tráfico
+ encaminado por la inspección HTTPS, el proxy local aplica las reglas de destino y los paquetes gestionados activados al cliente original antes
+ de abrir la conexión con el servidor de origen; una regla que nombra una URL también se aplica allí, después del descifrado, y responde con un
+ estado que la app puede leer. Esas son las dos únicas partes de Flowlight que actúan en lugar de informar — el muestreador nettop no puede
+ rechazar nada en absoluto, así que en un Mac que captura con el muestreador no se aplica ninguna regla, y Flowlight lo dice en lugar de ofrecer
+ un interruptor que no hace nada.
Aun así no es impedir. Un agente que puede ejecutar un shell puede hacer a mano lo que habría hecho la herramienta rechazada. Un rechazo que
puedes deshacer con un clic es un rechazo por el que el siguiente intento del agente puede pasar. El tráfico local, el DNS, los servicios de
Apple y las propias conexiones de Flowlight no se rechazan nunca, porque un Mac bloqueado es peor resultado que un agente bloqueado. Y la
aplicación de las reglas caduca unos segundos después de que Flowlight deje de ejecutarse: un rechazo que nadie puede ver es peor que ninguno.
-
La inspección y el rechazo no se combinan. El tráfico encaminado por el propio proxy de Flowlight sale del Mac como tráfico
- de Flowlight, y Flowlight no se rechaza nunca a sí mismo — así que una app cuyas peticiones estás leyendo es una app cuyas conexiones las
- reglas ya no detienen. Activar la inspección para ver más te cuesta la aplicación de las reglas, y hasta ahora nada en la pantalla lo decía.
+
La inspección conserva el rechazo por nombre de host, con un límite. El tráfico encaminado por el proxy de Flowlight sale del
+ Mac como tráfico exento de Flowlight, así que el proxy decide primero las reglas de destino normales y los paquetes gestionados activados usando
+ la identidad original de la app. Puede hacerlo incluso con túneles que fijan certificados o que nunca se descifran, porque CONNECT nombra el host.
+ Las listas de permitidos de agentes siguen siendo una decisión de la Extensión de red y no se aplican a ese tramo hacia el servidor de origen a
+ través del proxy; el tráfico sin un propietario fiable o sin nombre de host se permite en vez de rechazarse sin que nadie lo vea.
Lo que guarda Flowlight mismo
Metadatos de tráfico en una base de datos SQLite local, con una retención publicada. Con la inspección HTTPS activada guarda además los
diff --git a/docs/fr/docs/index.html b/docs/fr/docs/index.html
index e1fa7b89..b3bf78e0 100644
--- a/docs/fr/docs/index.html
+++ b/docs/fr/docs/index.html
@@ -48,7 +48,7 @@
-
+
Règles › Packs contient trois listes officielles de noms d’hôte : Publicité, Suivi intersites et Confidentialité et télémétrie. Elles sont visibles dès le départ et toutes désactivées par défaut. Le blocage nécessite l’Extension réseau ; l’échantillonneur nettop ne peut qu’observer.
Uniquement des mises à jour officielles et signées. Flowlight consulte une source HTTPS fixe unique pour rechercher des catalogues plus récents signés par l’éditeur. Les vérifications automatiques sont activées par défaut et peuvent être désactivées ici. Une signature authentifie le contenu, mais ne l’active pas : un nouveau pack reste désactivé et, si les destinations changent, l’application d’un pack activé est suspendue jusqu’à ce que vous examiniez la nouvelle version exacte et appuyiez sur Enregistrer. Pendant une panne, le contenu accepté peut rester disponible comme dernière version valide connue, visiblement signalée comme obsolète. Il s’agit de blocages réseau au niveau des noms d’hôte : ils ne peuvent pas supprimer les vides dans le DOM ou la mise en page, les superpositions du navigateur, les fenêtres contextuelles générées par des scripts ni les fenêtres d’applications natives.
+
Quand l’inspection HTTPS achemine une app par le proxy de Flowlight, celui-ci décide des règles de destination ordinaires et des packs gérés activés d’après l’identité d’origine de l’app, avant d’ouvrir toute connexion au serveur d’origine. Pour les tunnels CONNECT épinglés ou jamais déchiffrés, cela fonctionne par nom d’hôte ; les listes d’autorisation des agents restent limitées au chemin direct par l’Extension réseau. Si le propriétaire ou le nom d’hôte est inconnu, la connexion est autorisée.
Listes d’autorisation
Sélectionnez un agent dans Agents IA et utilisez Liste d’autorisation dans son détail pour dire où il peut se connecter. Saisissez des domaines
@@ -474,8 +475,7 @@
Refuser des connexions
Flowlight lui-même. Un Mac bloqué est un pire résultat qu’un agent bloqué : en cas de doute, la connexion est donc autorisée et enregistrée.
L’application des refus cesse aussi quelques secondes après l’arrêt de Flowlight — un refus que personne ne peut voir est pire que pas de refus, et le moyen
de l’annuler vit dans l’app.
-
L’inspection HTTPS et le blocage ne se combinent pas : le trafic acheminé par le proxy de Flowlight sort comme trafic de Flowlight, qui n’est
- jamais refusé.
+
Pour une app acheminée par l’inspection HTTPS, le proxy de Flowlight décide des règles de destination ordinaires et des packs gérés activés d’après l’identité d’origine de l’app, avant d’ouvrir toute connexion au serveur d’origine. Pour les tunnels CONNECT épinglés ou jamais déchiffrés, il le fait par nom d’hôte. Les listes d’autorisation des agents restent limitées au chemin direct par l’Extension réseau ; si le propriétaire ou le nom d’hôte est inconnu, la connexion est autorisée.
Garde-fous des agents : bloquer un outil, un serveur ou une ressource
Quels outils cet agent peut-il utiliser est une question différente de quels hôtes peut-il joindre : elle a donc sa propre
diff --git a/docs/fr/threat-model/index.html b/docs/fr/threat-model/index.html
index 6adb0c2d..9817461b 100644
--- a/docs/fr/threat-model/index.html
+++ b/docs/fr/threat-model/index.html
@@ -48,7 +48,7 @@
-
+
Une règle qui refuse des connexions est exécutée par l’extension réseau, sur le chemin, avant que la connexion ne soit faite. Une règle qui
- nomme une URL est exécutée par le proxy d’inspection, qui répond à la requête avec un statut que l’app peut lire. Ce sont les deux seules
- parties de Flowlight qui agissent au lieu de rapporter — l’échantillonneur nettop, lui, ne peut rien refuser du tout : sur un Mac qui
- capture avec l’échantillonneur, aucune règle n’est appliquée, et Flowlight le dit plutôt que de proposer un interrupteur qui ne fait rien.
+
Une règle qui refuse des connexions est normalement exécutée par l’extension réseau, sur le chemin, avant que la connexion ne soit faite. Pour
+ le trafic acheminé par l’inspection HTTPS, le proxy local applique les règles de destination et les packs gérés activés au client d’origine
+ avant d’ouvrir la connexion au serveur d’origine ; une règle qui nomme une URL y est également exécutée après le déchiffrement, avec un
+ statut que l’app peut lire. Ce sont les deux seules parties de Flowlight qui agissent au lieu de rapporter — l’échantillonneur nettop, lui, ne
+ peut rien refuser du tout : sur un Mac qui capture avec l’échantillonneur, aucune règle n’est appliquée, et Flowlight le dit plutôt que de
+ proposer un interrupteur qui ne fait rien.
Ce n’est pourtant toujours pas empêcher. Un agent qui peut lancer un shell peut faire à la main ce que l’outil refusé aurait fait. Un refus que
vous pouvez annuler d’un clic est un refus que la tentative suivante d’un agent peut franchir. Le trafic local, le DNS, les services d’Apple et
les connexions de Flowlight lui-même ne sont jamais refusés, parce qu’un Mac bloqué est un pire résultat qu’un agent bloqué. Et l’application
des refus cesse quelques secondes après l’arrêt de Flowlight : un refus que personne ne peut voir est pire que pas de refus.
-
Inspection et refus ne se combinent pas. Le trafic passé par le proxy de Flowlight lui-même quitte le Mac comme du trafic de
- Flowlight, et Flowlight ne se refuse jamais lui-même — une app dont vous lisez les requêtes est donc une app dont les règles n’arrêtent plus
- les connexions. Activer l’inspection pour en voir plus vous coûte l’application des refus, et rien à l’écran ne le disait jusqu’ici.
+
L’inspection préserve le refus par nom d’hôte, avec une limite. Le trafic acheminé par le proxy de Flowlight quitte le Mac comme
+ du trafic Flowlight exempté ; le proxy décide donc d’abord des règles de destination ordinaires et des packs gérés activés en utilisant
+ l’identité d’origine de l’app. Il peut le faire même pour les tunnels épinglés ou jamais déchiffrés, car CONNECT nomme l’hôte. Les listes
+ d’autorisation des agents restent une décision de l’Extension réseau et ne s’appliquent pas à ce trajet vers le serveur d’origine par le proxy ;
+ le trafic sans propriétaire fiable ou sans nom d’hôte est autorisé plutôt que refusé sans que personne ne le voie.
Ce que Flowlight lui-même conserve
Des métadonnées de trafic dans une base de données SQLite locale, avec une durée de conservation publiée. Avec l’inspection HTTPS activée, il
diff --git a/docs/it/docs/index.html b/docs/it/docs/index.html
index 7e8a06a8..d8a0caf2 100644
--- a/docs/it/docs/index.html
+++ b/docs/it/docs/index.html
@@ -48,7 +48,7 @@
-
+
Regole › Pacchetti contiene tre elenchi ufficiali di nomi host: Pubblicità, Tracciamento tra siti e Privacy e telemetria. Sono visibili fin dall’inizio e tutti disattivati per impostazione predefinita. Il blocco richiede l’Estensione di rete; il campionatore nettop può solo osservare.
Solo aggiornamenti ufficiali e firmati. Flowlight controlla un’unica fonte HTTPS fissa per trovare cataloghi più recenti firmati dall’editore. I controlli automatici sono attivi per impostazione predefinita e possono essere disattivati qui. Una firma autentica il contenuto, ma non lo attiva: un nuovo pacchetto resta disattivato e, se cambiano le destinazioni, l’applicazione di un pacchetto attivo viene sospesa finché non esamini l’esatta nuova versione e premi Salva. Durante un’interruzione, i contenuti accettati possono restare disponibili come ultima versione valida nota, visibilmente indicata come obsoleta. Si tratta di blocchi di rete a livello di nome host: non possono eliminare spazi vuoti nel DOM o nel layout, sovrapposizioni del browser, popup generati da script o finestre di app native.
+
Quando l’ispezione HTTPS instrada un’app attraverso il proxy di Flowlight, questo decide le normali regole di destinazione e i pacchetti gestiti attivi usando l’identità originale dell’app prima di aprire qualsiasi connessione al server di origine. Per i tunnel CONNECT con pinning o mai decifrati, ciò funziona in base al nome host; gli elenchi consentiti degli agenti restano limitati al percorso diretto attraverso l’Estensione di rete. Se il proprietario o il nome host è sconosciuto, la connessione viene consentita.
Elenchi consentiti
Seleziona un agente in Agenti IA e usa Elenco consentiti nel suo dettaglio per dire dove può connettersi. Inserisci domini
@@ -472,8 +473,7 @@
Rifiutare le connessioni
stesso. Un Mac bloccato è un esito peggiore di un agente bloccato, quindi nel dubbio la connessione viene consentita e registrata.
L'applicazione decade anche pochi secondi dopo che Flowlight smette di funzionare: un rifiuto che nessuno può vedere è peggio di nessun rifiuto, e il modo
per annullarne uno sta nell'app.
-
Ispezione HTTPS e blocco non si combinano: il traffico instradato attraverso il proxy di Flowlight esce come traffico di Flowlight, che non viene
- mai rifiutato.
+
Per un’app instradata attraverso l’ispezione HTTPS, il proxy di Flowlight decide le normali regole di destinazione e i pacchetti gestiti attivi usando l’identità originale dell’app prima di aprire qualsiasi connessione al server di origine. Per i tunnel CONNECT con pinning o mai decifrati, lo fa in base al nome host. Gli elenchi consentiti degli agenti restano limitati al percorso diretto attraverso l’Estensione di rete; se il proprietario o il nome host è sconosciuto, la connessione viene consentita.
Protezioni per gli agenti: bloccare uno strumento, un server o una risorsa
Quali strumenti può usare questo agente è una domanda diversa da quali host può raggiungere, quindi ha una
diff --git a/docs/it/threat-model/index.html b/docs/it/threat-model/index.html
index 3717f41f..98a7df80 100644
--- a/docs/it/threat-model/index.html
+++ b/docs/it/threat-model/index.html
@@ -48,7 +48,7 @@
-
+
Una regola che rifiuta le connessioni viene eseguita dall'estensione di rete, sul percorso, prima che la connessione venga stabilita. Una
- regola che nomina un URL viene eseguita invece dal proxy di ispezione, che risponde alla richiesta con uno stato che l'app può leggere. Sono le
- sole due parti di Flowlight che agiscono invece di riferire — il campionatore nettop non può rifiutare proprio nulla, quindi su un Mac che
- acquisisce con il campionatore nessuna regola viene applicata, e Flowlight lo dice invece di offrire un interruttore che non fa niente.
+
Una regola che rifiuta le connessioni viene normalmente eseguita dall'estensione di rete, sul percorso, prima che la connessione venga stabilita.
+ Per il traffico instradato attraverso l'ispezione HTTPS, il proxy locale applica le regole di destinazione e i pacchetti gestiti attivi al client
+ originale prima di aprire la connessione al server di origine; una regola che nomina un URL viene eseguita anche lì, dopo la decifratura, con uno
+ stato che l'app può leggere. Sono le sole due parti di Flowlight che agiscono invece di riferire — il campionatore nettop non può rifiutare
+ proprio nulla, quindi su un Mac che acquisisce con il campionatore nessuna regola viene applicata, e Flowlight lo dice invece di offrire un
+ interruttore che non fa niente.
Non è comunque impedire. Un agente che può eseguire una shell può fare a mano ciò che avrebbe fatto lo strumento rifiutato. Un rifiuto che
puoi annullare con un clic è un rifiuto attraverso cui il tentativo successivo di un agente può passare. Il traffico locale, il DNS, i servizi
di Apple e le connessioni di Flowlight stesso non vengono mai rifiutati, perché un Mac bloccato è un esito peggiore di un agente bloccato. E
l'applicazione decade pochi secondi dopo che Flowlight smette di funzionare: un rifiuto che nessuno può vedere è peggio di nessun rifiuto.
-
Ispezione e rifiuto non si combinano. Il traffico instradato attraverso il proxy di Flowlight stesso lascia il Mac come
- traffico di Flowlight, e Flowlight non rifiuta mai se stesso — quindi un'app di cui stai leggendo le richieste è un'app le cui connessioni le
- regole non fermano più. Attivare l'ispezione per vedere di più ti costa l'applicazione delle regole, e finora nulla sullo schermo lo diceva.
+
L'ispezione mantiene il rifiuto per nome host, con un limite. Il traffico instradato attraverso il proxy di Flowlight lascia il
+ Mac come traffico Flowlight esente, quindi il proxy decide prima le normali regole di destinazione e i pacchetti gestiti attivi usando l'identità
+ originale dell'app. Può farlo anche per tunnel con pinning o mai decifrati, perché CONNECT specifica l'host. Gli elenchi consentiti degli agenti
+ restano una decisione dell'Estensione di rete e non si applicano a quel tratto verso il server di origine attraverso il proxy; il traffico senza
+ un proprietario attendibile o senza nome host viene consentito invece di essere rifiutato senza che nessuno lo veda.
Cosa conserva Flowlight stesso
Metadati del traffico in un database SQLite locale, con una conservazione pubblicata. Con l'ispezione HTTPS attiva conserva anche i corpi delle
diff --git a/docs/ja/docs/index.html b/docs/ja/docs/index.html
index bdc62892..d701e1c7 100644
--- a/docs/ja/docs/index.html
+++ b/docs/ja/docs/index.html
@@ -48,7 +48,7 @@
-
+
규칙 › 팩에는 광고, 사이트 간 추적, 개인정보 보호 및 원격 측정이라는 세 가지 공식 호스트 이름 목록이 있습니다. 처음부터 표시되며 모두 기본적으로 꺼져 있습니다. 차단하려면 네트워크 확장 프로그램이 필요하며, nettop 샘플러는 관찰만 할 수 있습니다.
공식 서명 업데이트만 허용됩니다. Flowlight는 게시자가 서명한 최신 카탈로그가 있는지 하나의 고정 HTTPS 소스를 확인합니다. 자동 확인은 기본적으로 켜져 있으며 여기에서 끌 수 있습니다. 서명은 콘텐츠를 인증하지만 활성화하지는 않습니다. 새 팩은 꺼진 상태로 유지되며, 대상이 변경되면 활성화된 팩은 정확한 새 버전을 검토하고 저장을 누를 때까지 적용되지 않습니다. 서비스 중단 중에도 승인된 콘텐츠가 눈에 띄게 오래된 것으로 표시된 마지막 정상 버전으로 유지될 수 있습니다. 이는 호스트 이름 수준의 네트워크 차단이므로 DOM이나 레이아웃의 빈 공간, 브라우저 오버레이, 스크립트로 생성된 팝업 또는 네이티브 앱 창은 제거할 수 없습니다.
+
HTTPS 검사가 앱을 Flowlight의 프록시로 보내면, 일반 대상 규칙과 활성화된 관리형 팩은 원본 서버 연결이 열리기 전에 원래 앱의 신원을 사용해 결정됩니다. 인증서가 고정되었거나 복호화되지 않는 CONNECT 터널도 호스트 이름으로 판정할 수 있습니다. 에이전트 허용 목록은 계속 네트워크 확장을 직접 지나는 경로에만 적용됩니다. 소유자나 호스트 이름을 알 수 없으면 연결을 허용합니다.
허용 목록
AI 에이전트에서 에이전트를 고르고 상세의 허용 목록에서 어디에 연결할 수 있는지 정하세요.
@@ -468,8 +469,7 @@
연결 거부
트래픽입니다. 막힌 Mac은 막힌 에이전트보다 나쁜 결과이므로, 애매할 때는 연결을 허용하고 기록합니다. 또한 Flowlight가 멈추면
몇 초 뒤에 적용이 풀립니다. 아무도 볼 수 없는 거부는 거부가 없는 것보다 나쁘고, 그것을 되돌리는 방법은 앱 안에 있기
때문입니다.
-
HTTPS 검사와 차단은 함께 쓸 수 없습니다. Flowlight의 프록시를 거친 트래픽은 Flowlight 자신의 트래픽으로 나가는데, 그것은
- 절대 거부되지 않기 때문입니다.
+
HTTPS 검사를 거치는 앱은 Flowlight의 프록시가 원본 서버 연결을 열기 전에 원래 앱의 신원을 사용해 일반 대상 규칙과 활성화된 관리형 팩을 결정합니다. 인증서가 고정되었거나 복호화되지 않는 CONNECT 터널에서는 호스트 이름으로 판정합니다. 에이전트 허용 목록은 계속 네트워크 확장을 직접 지나는 경로에만 적용됩니다. 소유자나 호스트 이름을 알 수 없으면 연결을 허용합니다.
에이전트 가드레일: 도구나 서버, 리소스를 막기
이 에이전트가 어떤 도구를 쓸 수 있나는 어떤 호스트에 닿을 수 있나와 다른 질문이므로 자리도 따로
diff --git a/docs/ko/threat-model/index.html b/docs/ko/threat-model/index.html
index 86aa0d21..153866f2 100644
--- a/docs/ko/threat-model/index.html
+++ b/docs/ko/threat-model/index.html
@@ -48,7 +48,7 @@
-
+
연결을 거부하는 규칙을 실행하는 것은 네트워크 확장이며, 그 경로 안에서, 연결이 만들어지기 전에 실행합니다. URL을 지정하는
- 규칙은 검사 프록시가 대신 실행해, 앱이 읽을 수 있는 상태 코드로 요청에 답합니다. 보고하는 대신 실제로 손을 쓰는 Flowlight의
- 부분은 이 둘뿐입니다. nettop 샘플러는 아무것도 거부할 수 없으므로, 샘플러로 돌아가는 Mac에서는 어떤 규칙도 적용되지 않습니다.
- Flowlight는 아무 일도 하지 않는 스위치를 내놓는 대신 그렇다고 말합니다.
+
연결을 거부하는 규칙은 보통 네트워크 확장이 그 경로 안에서, 연결이 만들어지기 전에 실행합니다. HTTPS 검사를 거치는 트래픽은
+ 로컬 프록시가 원본 서버 연결을 열기 전에 원래 클라이언트에 대상 규칙과 활성화된 관리형 팩을 적용합니다. URL을 지정하는 규칙도
+ 복호화 후 그곳에서 실행되어, 앱이 읽을 수 있는 상태 코드로 응답합니다. 보고하는 대신 실제로 손을 쓰는 Flowlight의 부분은 이
+ 둘뿐입니다. nettop 샘플러는 아무것도 거부할 수 없으므로, 샘플러로 돌아가는 Mac에서는 어떤 규칙도 적용되지 않습니다. Flowlight는
+ 아무 일도 하지 않는 스위치를 내놓는 대신 그렇다고 말합니다.
그래도 예방은 아닙니다. 셸을 쓸 수 있는 에이전트는 거부된 도구가 했을 일을 직접 할 수 있습니다. 클릭 한 번으로 되돌릴 수 있는
거부는, 에이전트의 다음 시도가 걸어 나갈 수 있는 거부입니다. 로컬 트래픽과 DNS, Apple 기본 서비스, Flowlight 자신의 연결은 절대
거부되지 않습니다. 막힌 Mac은 막힌 에이전트보다 나쁜 결과이기 때문입니다. 그리고 Flowlight가 멈추면 몇 초 뒤에 적용이 풀립니다.
아무도 볼 수 없는 거부는 거부가 없는 것보다 나쁘기 때문입니다.
-
검사와 거부는 함께 가지 않습니다. Flowlight 자신의 프록시를 거친 트래픽은 Flowlight의 트래픽으로 Mac을
- 떠나고, Flowlight는 자기 자신을 거부하지 않습니다. 그러니 요청을 읽고 있는 앱은, 규칙으로는 더 이상 연결을 막을 수 없는
- 앱입니다. 더 보려고 검사를 켜는 일은 적용을 내주는 일이기도 한데, 화면에는 지금까지 그런 말이 없었습니다.
+
검사에서도 호스트 이름에 따른 거부는 유지되지만 경계가 있습니다. Flowlight의 프록시를 거친 트래픽은 예외 처리된
+ Flowlight 트래픽으로 Mac을 떠나므로, 프록시가 먼저 원래 앱의 신원을 사용해 일반 대상 규칙과 활성화된 관리형 팩을 결정합니다.
+ CONNECT가 호스트 이름을 지정하므로 인증서가 고정되었거나 복호화되지 않는 터널에서도 이 판정을 할 수 있습니다. 에이전트 허용 목록은
+ 네트워크 확장의 결정으로 남으며 프록시에서 원본 서버로 가는 구간에는 적용되지 않습니다. 신뢰할 수 있는 소유자나 호스트 이름이 없는
+ 트래픽은 보이지 않게 거부하지 않고 연결을 허용합니다.
Flowlight 자신이 갖고 있는 것
트래픽 메타데이터를 로컬 SQLite 데이터베이스에, 공개된 보관 기간만큼 갖고 있습니다. HTTPS 검사를 켜 두면 요청과 응답의 본문도
diff --git a/docs/llms-full.txt b/docs/llms-full.txt
index 9dff7033..7b3605a1 100644
--- a/docs/llms-full.txt
+++ b/docs/llms-full.txt
@@ -526,9 +526,11 @@ open build/Build/Products/Release/Flowlight.app
as visibly stale last-known-good during an outage; rollback, equivocation, revoked trust, or corrupt cached bytes fail closed.
Each pack has its own scope. Apply it globally to every app, or only to selected applications. Blocking needs
- the Network Extension: the nettop sampler can record traffic, but it cannot sit in a connection's path and refuse it. Essential
- system traffic remains allowed — including the services that carry Apple accounts, push notifications, software updates,
- certificate checks and iCloud — even when a pack applies globally.
+ the Network Extension: the nettop sampler can record traffic, but it cannot sit in a connection's path and refuse it. When HTTPS
+ inspection routes an app through Flowlight's proxy, enabled packs and ordinary destination rules are decided against that original
+ app before any origin connection opens. This also covers pinned and never-decrypted tunnels by hostname; agent allowlists remain
+ limited to the direct Network Extension path. Essential system traffic remains allowed — including the services that carry Apple
+ accounts, push notifications, software updates, certificate checks and iCloud — even when a pack applies globally.
Preview before enforcing. Test against recent traffic replays the proposed pack and scope against up
to seven days of retained traffic. It reports only decisions that would actually change after ordinary rules, the global pause,
@@ -1646,6 +1648,10 @@ October 20, 2026Latest
redirects, MIME, size, timeout, rollback, equivocation, revoked trust, corrupt cache and failed database commits cannot publish
unseen definitions or replace the active snapshot.
+ Keep managed packs enforcing during HTTPS inspection. Before the local proxy opens a CONNECT tunnel or
+ plaintext origin connection, it applies the extension-acknowledged pack snapshot to the original app identity. A refusal is
+ returned only after its activity record commits; failed persistence allows the connection instead of blocking invisibly.
+
0.15.3
October 19, 2026
@@ -3005,19 +3011,23 @@ Threat model
What blocking is, and isn't
- A rule that refuses a connection is carried out by the Network Extension, in the path, before the connection is made. A rule
- that names a URL is carried out by the inspection proxy instead, which answers the request with a status the app can read.
- Those are the only two parts of Flowlight that act rather than report — the nettop sampler can refuse nothing at all, so on a
- Mac running the sampler no rule is enforced, and Flowlight says so rather than offering a switch that does nothing.
+ A rule that refuses a connection is normally carried out by the Network Extension, in the path, before the connection is made.
+ For traffic routed through HTTPS inspection, the local proxy applies destination rules and enabled managed packs against the
+ original client before it opens the upstream connection; a rule that names a URL is also carried out there, after decryption,
+ with a status the app can read. Those are the only two parts of Flowlight that act rather than report — the nettop sampler can
+ refuse nothing at all, so on a Mac running the sampler no rule is enforced, and Flowlight says so rather than offering a switch
+ that does nothing.
It is still not prevention. An agent that can run a shell can do by hand what a refused tool would have done. A refusal you
can undo in one click is a refusal an agent's next attempt may walk through. Local traffic, DNS, Apple's own services and
Flowlight's own connections are never refused, because a blocked Mac is a worse outcome than a blocked agent. And enforcement
lapses a few seconds after Flowlight stops running: a refusal nobody can see is worse than none.
- Inspection and refusal do not combine. Traffic routed through Flowlight's own proxy leaves the Mac as
- Flowlight's traffic, and Flowlight never refuses itself — so an app whose requests you are reading is an app whose connections
- rules no longer stop. Turning inspection on to see more costs you enforcement, and nothing on screen previously said so.
+ Inspection preserves hostname refusal, with a boundary. Traffic routed through Flowlight's proxy leaves the
+ Mac as exempt Flowlight traffic, so the proxy decides ordinary destination rules and enabled managed packs first, using the
+ original app identity. It can do this even for pinned or never-decrypted tunnels because CONNECT names the host. Agent allowlists
+ remain a Network Extension decision and do not apply to that proxied upstream leg; traffic with no trustworthy owner or no
+ hostname fails open rather than being refused invisibly.
What Flowlight itself holds
diff --git a/docs/pt-PT/docs/index.html b/docs/pt-PT/docs/index.html
index 87b4e58f..cbcb7066 100644
--- a/docs/pt-PT/docs/index.html
+++ b/docs/pt-PT/docs/index.html
@@ -48,7 +48,7 @@
-
+
Regras › Pacotes contém três listas oficiais de nomes de anfitrião: Publicidade, Rastreio entre sites e Privacidade e telemetria. Estão visíveis desde o início e todas desativadas por predefinição. O bloqueio requer a Extensão de rede; o amostrador nettop apenas pode observar.
Apenas atualizações oficiais e assinadas. O Flowlight consulta uma única fonte HTTPS fixa para procurar catálogos mais recentes assinados pelo editor. As verificações automáticas estão ativas por predefinição e podem ser desativadas aqui. Uma assinatura autentica o conteúdo, mas não o ativa: um novo pacote permanece desativado e, se os destinos forem alterados, um pacote ativo deixa de ser aplicado até que reveja a nova versão exata e prima Guardar. Durante uma interrupção, o conteúdo aceite pode permanecer disponível como a última versão válida conhecida, visivelmente assinalada como desatualizada. Estes são bloqueios de rede ao nível do nome de anfitrião: não podem remover espaços vazios no DOM ou no esquema, sobreposições do navegador, janelas pop-up geradas por scripts ou janelas de aplicações nativas.
+
Quando a inspeção HTTPS encaminha uma app pelo proxy do Flowlight, este decide as regras de destino comuns e os pacotes geridos ativos com base na identidade original da app antes de abrir qualquer ligação ao servidor de origem. Nos túneis CONNECT com certificado fixado ou que nunca são decifrados, isto funciona pelo nome de anfitrião; as listas de permissões dos agentes continuam limitadas ao caminho direto pela Extensão de rede. Se o proprietário ou o nome de anfitrião for desconhecido, a ligação é permitida.
Listas de permissões
Selecione um agente em Agentes de IA e use a Lista de permissões no seu detalhe para dizer onde pode ligar-se. Introduza domínios
@@ -474,8 +475,7 @@
Recusar ligações
Flowlight. Um Mac bloqueado é um resultado pior do que um agente bloqueado, por isso, na dúvida, a ligação é permitida e registada.
A aplicação efetiva também caduca uns segundos depois de o Flowlight deixar de correr — uma recusa que ninguém consegue ver é pior do que nenhuma, e a maneira
de a desfazer está na app.
-
A inspeção HTTPS e o bloqueio não se combinam: o tráfego encaminhado pelo proxy do Flowlight sai como sendo do próprio Flowlight, que
- nunca é recusado.
+
Para uma app encaminhada pela inspeção HTTPS, o proxy do Flowlight decide as regras de destino comuns e os pacotes geridos ativos com base na identidade original da app antes de abrir qualquer ligação ao servidor de origem. Nos túneis CONNECT com certificado fixado ou que nunca são decifrados, fá-lo pelo nome de anfitrião. As listas de permissões dos agentes continuam limitadas ao caminho direto pela Extensão de rede; se o proprietário ou o nome de anfitrião for desconhecido, a ligação é permitida.
Proteções dos agentes: bloquear uma ferramenta, um servidor ou um recurso
Que ferramentas pode este agente usar é uma pergunta diferente de que anfitriões pode alcançar, por isso tem a sua própria
diff --git a/docs/pt-PT/threat-model/index.html b/docs/pt-PT/threat-model/index.html
index 1d06fc0c..5778114a 100644
--- a/docs/pt-PT/threat-model/index.html
+++ b/docs/pt-PT/threat-model/index.html
@@ -48,7 +48,7 @@
-
+
Uma regra que recusa ligações é levada a cabo pela extensão de rede, no caminho, antes de a ligação ser feita. Uma regra que nomeia um URL é
- levada a cabo pelo proxy de inspeção, que responde ao pedido com um estado que a app consegue ler. São as duas únicas partes do Flowlight que
- agem em vez de relatar — o amostrador nettop não consegue recusar nada, por isso num Mac que capta com o amostrador nenhuma regra é aplicada, e
- o Flowlight di-lo em vez de oferecer um interruptor que não faz nada.
+
Uma regra que recusa ligações é normalmente levada a cabo pela extensão de rede, no caminho, antes de a ligação ser feita. Para o tráfego
+ encaminhado pela inspeção HTTPS, o proxy local aplica as regras de destino e os pacotes geridos ativos ao cliente original antes de abrir a
+ ligação ao servidor de origem; uma regra que nomeia um URL também é aplicada ali, depois da desencriptação, com um estado que a app consegue ler.
+ São as duas únicas partes do Flowlight que agem em vez de relatar — o amostrador nettop não consegue recusar nada, por isso num Mac que capta com
+ o amostrador nenhuma regra é aplicada, e o Flowlight di-lo em vez de oferecer um interruptor que não faz nada.
Continua a não ser impedir. Um agente que consiga executar uma shell pode fazer à mão o que a ferramenta recusada teria feito. Uma recusa
que pode desfazer num clique é uma recusa por onde a tentativa seguinte de um agente pode passar. O tráfego local, o DNS, os serviços da
Apple e as ligações do próprio Flowlight nunca são recusados, porque um Mac bloqueado é um resultado pior do que um agente bloqueado. E a
aplicação efetiva caduca uns segundos depois de o Flowlight deixar de correr: uma recusa que ninguém consegue ver é pior do que nenhuma.
-
A inspeção e a recusa não se combinam. O tráfego encaminhado pelo próprio proxy do Flowlight sai do Mac como tráfego do
- Flowlight, e o Flowlight nunca se recusa a si mesmo — por isso uma app cujos pedidos está a ler é uma app cujas ligações as regras já não
- travam. Ligar a inspeção para ver mais custa-lhe a aplicação das regras, e até agora nada no ecrã o dizia.
+
A inspeção preserva a recusa por nome de anfitrião, com um limite. O tráfego encaminhado pelo proxy do Flowlight sai do Mac como
+ tráfego isento do Flowlight, por isso o proxy decide primeiro as regras de destino comuns e os pacotes geridos ativos com base na identidade
+ original da app. Consegue fazê-lo mesmo em túneis com certificado fixado ou nunca decifrados, porque CONNECT indica o anfitrião. As listas de
+ permissões dos agentes continuam a ser uma decisão da Extensão de rede e não se aplicam a esse trajeto pelo proxy até ao servidor de origem; o
+ tráfego sem um proprietário fiável ou sem nome de anfitrião é permitido em vez de ser recusado sem que ninguém o veja.
O que o próprio Flowlight guarda
Metadados de tráfego numa base de dados SQLite local, com uma retenção publicada. Com a inspeção HTTPS ligada, guarda também os corpos dos
diff --git a/docs/releases/index.html b/docs/releases/index.html
index f5c40a73..53464461 100644
--- a/docs/releases/index.html
+++ b/docs/releases/index.html
@@ -28,7 +28,7 @@
-
+
Fail closed at startup and on the wire. Capture waits for catalog verification plus saved exceptions;
redirects, MIME, size, timeout, rollback, equivocation, revoked trust, corrupt cache and failed database commits cannot publish
unseen definitions or replace the active snapshot.
+
Keep managed packs enforcing during HTTPS inspection. Before the local proxy opens a CONNECT tunnel or
+ plaintext origin connection, it applies the extension-acknowledged pack snapshot to the original app identity. A refusal is
+ returned only after its activity record commits; failed persistence allows the connection instead of blocking invisibly.
A rule that refuses a connection is carried out by the Network Extension, in the path, before the connection is made. A rule
- that names a URL is carried out by the inspection proxy instead, which answers the request with a status the app can read.
- Those are the only two parts of Flowlight that act rather than report — the nettop sampler can refuse nothing at all, so on a
- Mac running the sampler no rule is enforced, and Flowlight says so rather than offering a switch that does nothing.
+
A rule that refuses a connection is normally carried out by the Network Extension, in the path, before the connection is made.
+ For traffic routed through HTTPS inspection, the local proxy applies destination rules and enabled managed packs against the
+ original client before it opens the upstream connection; a rule that names a URL is also carried out there, after decryption,
+ with a status the app can read. Those are the only two parts of Flowlight that act rather than report — the nettop sampler can
+ refuse nothing at all, so on a Mac running the sampler no rule is enforced, and Flowlight says so rather than offering a switch
+ that does nothing.
It is still not prevention. An agent that can run a shell can do by hand what a refused tool would have done. A refusal you
can undo in one click is a refusal an agent's next attempt may walk through. Local traffic, DNS, Apple's own services and
Flowlight's own connections are never refused, because a blocked Mac is a worse outcome than a blocked agent. And enforcement
lapses a few seconds after Flowlight stops running: a refusal nobody can see is worse than none.
-
Inspection and refusal do not combine. Traffic routed through Flowlight's own proxy leaves the Mac as
- Flowlight's traffic, and Flowlight never refuses itself — so an app whose requests you are reading is an app whose connections
- rules no longer stop. Turning inspection on to see more costs you enforcement, and nothing on screen previously said so.
+
Inspection preserves hostname refusal, with a boundary. Traffic routed through Flowlight's proxy leaves the
+ Mac as exempt Flowlight traffic, so the proxy decides ordinary destination rules and enabled managed packs first, using the
+ original app identity. It can do this even for pinned or never-decrypted tunnels because CONNECT names the host. Agent allowlists
+ remain a Network Extension decision and do not apply to that proxied upstream leg; traffic with no trustworthy owner or no
+ hostname fails open rather than being refused invisibly.
What Flowlight itself holds
Traffic metadata in a local SQLite database, with published retention. With HTTPS inspection on it also holds request and
diff --git a/docs/zh-Hans/docs/index.html b/docs/zh-Hans/docs/index.html
index 07893048..05bd9418 100644
--- a/docs/zh-Hans/docs/index.html
+++ b/docs/zh-Hans/docs/index.html
@@ -48,7 +48,7 @@
-
+
Regeln › Pakete enthält drei offizielle Hostnamenlisten: Werbung, Websiteübergreifendes Tracking und Datenschutz und Telemetrie. Sie sind von Anfang an sichtbar und standardmäßig alle deaktiviert. Zum Blockieren ist die Netzwerkerweiterung erforderlich; der nettop-Sampler kann nur beobachten.
Ausschließlich offizielle, signierte Updates. Flowlight prüft eine einzige festgelegte HTTPS-Quelle auf neuere, vom Herausgeber signierte Kataloge. Automatische Prüfungen sind standardmäßig aktiviert und können hier deaktiviert werden. Eine Signatur authentifiziert den Inhalt, aktiviert ihn aber nicht: Ein neues Paket bleibt deaktiviert, und bei geänderten Zielen wird ein aktiviertes Paket nicht mehr durchgesetzt, bis du die genaue neue Version geprüft und auf Sichern geklickt hast. Akzeptierte Inhalte können während eines Ausfalls als sichtbar veralteter letzter bekannter funktionsfähiger Stand erhalten bleiben. Dies sind Netzwerksperren auf Hostnamenebene: Sie können keine Lücken im DOM oder Layout, Browser-Overlays, skriptgesteuerten Pop-ups oder Fenster nativer Apps entfernen.
+
Wenn die HTTPS-Inspektion eine App durch Flowlights Proxy leitet, entscheidet er anhand der ursprünglichen App-Identität über gewöhnliche Zielregeln und aktivierte verwaltete Pakete, bevor eine Verbindung zum Ursprungsserver geöffnet wird. Bei angepinnten oder nie entschlüsselten CONNECT-Tunneln funktioniert das anhand des Hostnamens; Agenten-Erlaubnislisten bleiben auf den direkten Weg über die Netzwerkerweiterung beschränkt. Ist der Eigentümer oder Hostname unbekannt, wird die Verbindung im Zweifel zugelassen.
Erlaubnislisten
Wähle einen Agenten in KI-Agenten und sage über Erlaubnisliste in seinem Detail, wohin er sich verbinden darf. Trage Domains
@@ -406,8 +407,7 @@
Verbindungen verweigern
Datenverkehr. Ein blockierter Mac ist schlimmer als ein blockierter Agent, im Zweifel wird die Verbindung also erlaubt und aufgezeichnet.
Die Durchsetzung endet außerdem wenige Sekunden nachdem Flowlight nicht mehr läuft – eine Verweigerung, die niemand sehen kann, ist schlimmer als keine, und der Weg,
sie aufzuheben, liegt in der App.
-
HTTPS-Inspektion und Blockieren lassen sich nicht kombinieren: Datenverkehr, der über Flowlights Proxy läuft, geht als Flowlights eigener hinaus und wird
- nie verweigert.
+
Für eine App, die durch die HTTPS-Inspektion geleitet wird, entscheidet Flowlights Proxy gewöhnliche Zielregeln und aktivierte verwaltete Pakete anhand der ursprünglichen App-Identität, bevor er eine Verbindung zum Ursprungsserver öffnet. Bei angepinnten oder nie entschlüsselten CONNECT-Tunneln geschieht das anhand des Hostnamens. Agenten-Erlaubnislisten bleiben auf den direkten Weg über die Netzwerkerweiterung beschränkt; ist der Eigentümer oder Hostname unbekannt, wird die Verbindung im Zweifel zugelassen.
Schutzregeln für Agenten: ein Tool, einen Server oder eine Ressource blockieren
Welche Tools darf dieser Agent nutzen ist eine andere Frage als welche Hosts darf er erreichen, und hat darum ihren eigenen
diff --git a/site/pages/de/threat-model.html b/site/pages/de/threat-model.html
index 13a15e25..8b21c993 100644
--- a/site/pages/de/threat-model.html
+++ b/site/pages/de/threat-model.html
@@ -87,19 +87,22 @@
Was der Inspektion entgeht
welches von beidem gilt.
Was Blockieren ist und was nicht
-
Eine Regel, die Verbindungen verweigert, wird von der Netzwerkerweiterung ausgeführt, im Weg der Verbindung, bevor sie zustande kommt. Eine
- Regel, die eine URL nennt, führt stattdessen der Inspektions-Proxy aus, der die Anfrage mit einem Status beantwortet, den die App lesen kann.
- Das sind die einzigen zwei Teile von Flowlight, die handeln statt zu berichten – der nettop-Sampler kann überhaupt nichts verweigern, auf einem
- Mac, der mit dem Sampler erfasst, wird also keine Regel durchgesetzt, und Flowlight sagt das, statt einen Schalter anzubieten, der nichts tut.
+
Eine Regel, die Verbindungen verweigert, wird normalerweise von der Netzwerkerweiterung ausgeführt, im Weg der Verbindung, bevor sie zustande kommt.
+ Bei Datenverkehr, der durch die HTTPS-Inspektion geleitet wird, wendet der lokale Proxy Zielregeln und aktivierte verwaltete Pakete auf den
+ ursprünglichen Client an, bevor er die Verbindung zum Ursprungsserver öffnet; eine Regel, die eine URL nennt, führt er nach der Entschlüsselung
+ ebenfalls dort aus und antwortet mit einem Status, den die App lesen kann. Das sind die einzigen zwei Teile von Flowlight, die handeln statt zu
+ berichten – der nettop-Sampler kann überhaupt nichts verweigern, auf einem Mac, der mit dem Sampler erfasst, wird also keine Regel durchgesetzt,
+ und Flowlight sagt das, statt einen Schalter anzubieten, der nichts tut.
Verhindern ist es trotzdem nicht. Ein Agent, der eine Shell ausführen kann, kann von Hand tun, was ein verweigertes Tool getan hätte. Eine
Verweigerung, die du mit einem Klick aufheben kannst, ist eine Verweigerung, durch die der nächste Versuch eines Agenten hindurchgehen kann.
Lokaler Datenverkehr, DNS, Apples eigene Dienste und Flowlights eigene Verbindungen werden nie verweigert, denn ein blockierter Mac ist ein
schlechteres Ergebnis als ein blockierter Agent. Und die Durchsetzung endet wenige Sekunden nachdem Flowlight nicht mehr läuft: Eine
Verweigerung, die niemand sehen kann, ist schlimmer als keine.
-
Inspektion und Verweigerung lassen sich nicht verbinden. Datenverkehr, der über Flowlights eigenen Proxy geleitet wird,
- verlässt den Mac als Flowlights Datenverkehr, und Flowlight verweigert sich selbst nie – eine App, deren Anfragen du liest, ist also eine App,
- deren Verbindungen Regeln nicht mehr stoppen. Die Inspektion einzuschalten, um mehr zu sehen, kostet dich die Durchsetzung, und auf dem
- Bildschirm stand das vorher nirgends.
+
Die Inspektion erhält die Verweigerung nach Hostnamen, mit einer Grenze. Datenverkehr, der durch Flowlights Proxy geleitet wird,
+ verlässt den Mac als ausgenommener Flowlight-Datenverkehr. Deshalb entscheidet der Proxy zuerst anhand der ursprünglichen App-Identität über
+ gewöhnliche Zielregeln und aktivierte verwaltete Pakete. Das funktioniert auch bei angepinnten oder nie entschlüsselten Tunneln, weil CONNECT den
+ Host nennt. Agenten-Erlaubnislisten bleiben eine Entscheidung der Netzwerkerweiterung und gelten nicht für diesen über den Proxy geleiteten Weg
+ zum Ursprungsserver; Datenverkehr ohne vertrauenswürdigen Eigentümer oder Hostnamen wird im Zweifel zugelassen, statt unsichtbar verweigert zu werden.
Was Flowlight selbst aufbewahrt
Metadaten zum Datenverkehr in einer lokalen SQLite-Datenbank, mit veröffentlichten Aufbewahrungsfristen. Bei eingeschalteter
diff --git a/site/pages/docs.html b/site/pages/docs.html
index 7ff264aa..6b41be8c 100644
--- a/site/pages/docs.html
+++ b/site/pages/docs.html
@@ -401,9 +401,11 @@
Managed ad and tracker packs
and press Save. If content changes while its editor is open, the stale Save is refused. An accepted catalog may remain available
as visibly stale last-known-good during an outage; rollback, equivocation, revoked trust, or corrupt cached bytes fail closed.
Each pack has its own scope. Apply it globally to every app, or only to selected applications. Blocking needs
- the Network Extension: the nettop sampler can record traffic, but it cannot sit in a connection's path and refuse it. Essential
- system traffic remains allowed — including the services that carry Apple accounts, push notifications, software updates,
- certificate checks and iCloud — even when a pack applies globally.
+ the Network Extension: the nettop sampler can record traffic, but it cannot sit in a connection's path and refuse it. When HTTPS
+ inspection routes an app through Flowlight's proxy, enabled packs and ordinary destination rules are decided against that original
+ app before any origin connection opens. This also covers pinned and never-decrypted tunnels by hostname; agent allowlists remain
+ limited to the direct Network Extension path. Essential system traffic remains allowed — including the services that carry Apple
+ accounts, push notifications, software updates, certificate checks and iCloud — even when a pack applies globally.
Preview before enforcing.Test against recent traffic replays the proposed pack and scope against up
to seven days of retained traffic. It reports only decisions that would actually change after ordinary rules, the global pause,
existing packs and agent policies are taken into account: the affected apps and destinations, connection counts, and bytes.
diff --git a/site/pages/es/docs.html b/site/pages/es/docs.html
index a3c70cdc..c4a06c08 100644
--- a/site/pages/es/docs.html
+++ b/site/pages/es/docs.html
@@ -382,6 +382,7 @@
Reglas
Paquetes gestionados de publicidad y rastreadores
Reglas › Paquetes contiene tres listas oficiales de nombres de host: Publicidad, Seguimiento entre sitios y Privacidad y telemetría. Son visibles desde el principio y todas están desactivadas de forma predeterminada. El bloqueo requiere la Extensión de red; el muestreador nettop solo puede observar.
Solo actualizaciones oficiales y firmadas. Flowlight consulta una única fuente HTTPS fija para buscar catálogos más recientes firmados por el editor. Las comprobaciones automáticas están activadas de forma predeterminada y pueden desactivarse aquí. Una firma autentica el contenido, pero no lo activa: un paquete nuevo permanece desactivado y, si cambian los destinos, un paquete activado deja de aplicarse hasta que revises la nueva versión exacta y pulses Guardar. Durante una interrupción, el contenido aceptado puede conservarse como la última versión válida conocida, marcada visiblemente como obsoleta. Estos son bloqueos de red a nivel de nombre de host: no pueden eliminar huecos del DOM o del diseño, superposiciones del navegador, ventanas emergentes generadas por scripts ni ventanas de aplicaciones nativas.
+
Cuando la inspección HTTPS encamina una app por el proxy de Flowlight, este decide las reglas de destino normales y los paquetes gestionados activados usando la identidad original de la app antes de abrir ninguna conexión con el servidor de origen. En los túneles CONNECT con certificado fijado o que nunca se descifran, esto funciona por nombre de host; las listas de permitidos de agentes siguen limitadas al camino directo por la Extensión de red. Si no se conoce el propietario o el nombre de host, la conexión se permite.
Listas de permitidos
Selecciona un agente en Agentes de IA y usa Lista de permitidos en su detalle para indicar adónde puede conectar. Introduce dominios
@@ -401,8 +402,7 @@
Rechazar conexiones
de Flowlight. Un Mac bloqueado es peor resultado que un agente bloqueado, así que ante la duda la conexión se permite y se registra.
La aplicación de las reglas también caduca unos segundos después de que Flowlight deje de ejecutarse: un rechazo que nadie puede ver es peor que ninguno, y la forma
de deshacer uno está en la app.
-
La inspección HTTPS y el bloqueo no se combinan: el tráfico encaminado por el proxy de Flowlight sale como tráfico propio de Flowlight, que
- nunca se rechaza.
+
Para una app encaminada por la inspección HTTPS, el proxy de Flowlight decide las reglas de destino normales y los paquetes gestionados activados usando la identidad original de la app antes de abrir ninguna conexión con el servidor de origen. En los túneles CONNECT con certificado fijado o que nunca se descifran, lo hace por nombre de host. Las listas de permitidos de agentes siguen limitadas al camino directo por la Extensión de red; si no se conoce el propietario o el nombre de host, la conexión se permite.
Salvaguardas de agente: bloquear una herramienta, un servidor o un recurso
Qué herramientas puede usar este agente es una pregunta distinta de a qué hosts puede llegar, así que tiene su propia
diff --git a/site/pages/es/threat-model.html b/site/pages/es/threat-model.html
index f11af01e..4a95d299 100644
--- a/site/pages/es/threat-model.html
+++ b/site/pages/es/threat-model.html
@@ -84,17 +84,21 @@
Lo que la inspección se pierde
cosas se aplica, app por app.
Qué es bloquear, y qué no
-
De una regla que rechaza conexiones se encarga la extensión de red, en el camino, antes de que la conexión se haga. De una regla que nombra
- una URL se encarga en cambio el proxy de inspección, que responde a la petición con un estado que la app puede leer. Esas son las dos únicas
- partes de Flowlight que actúan en lugar de informar — el muestreador nettop no puede rechazar nada en absoluto, así que en un Mac que captura
- con el muestreador no se aplica ninguna regla, y Flowlight lo dice en lugar de ofrecer un interruptor que no hace nada.
+
De una regla que rechaza conexiones se encarga normalmente la extensión de red, en el camino, antes de que la conexión se haga. Para el tráfico
+ encaminado por la inspección HTTPS, el proxy local aplica las reglas de destino y los paquetes gestionados activados al cliente original antes
+ de abrir la conexión con el servidor de origen; una regla que nombra una URL también se aplica allí, después del descifrado, y responde con un
+ estado que la app puede leer. Esas son las dos únicas partes de Flowlight que actúan en lugar de informar — el muestreador nettop no puede
+ rechazar nada en absoluto, así que en un Mac que captura con el muestreador no se aplica ninguna regla, y Flowlight lo dice en lugar de ofrecer
+ un interruptor que no hace nada.
Aun así no es impedir. Un agente que puede ejecutar un shell puede hacer a mano lo que habría hecho la herramienta rechazada. Un rechazo que
puedes deshacer con un clic es un rechazo por el que el siguiente intento del agente puede pasar. El tráfico local, el DNS, los servicios de
Apple y las propias conexiones de Flowlight no se rechazan nunca, porque un Mac bloqueado es peor resultado que un agente bloqueado. Y la
aplicación de las reglas caduca unos segundos después de que Flowlight deje de ejecutarse: un rechazo que nadie puede ver es peor que ninguno.
-
La inspección y el rechazo no se combinan. El tráfico encaminado por el propio proxy de Flowlight sale del Mac como tráfico
- de Flowlight, y Flowlight no se rechaza nunca a sí mismo — así que una app cuyas peticiones estás leyendo es una app cuyas conexiones las
- reglas ya no detienen. Activar la inspección para ver más te cuesta la aplicación de las reglas, y hasta ahora nada en la pantalla lo decía.
+
La inspección conserva el rechazo por nombre de host, con un límite. El tráfico encaminado por el proxy de Flowlight sale del
+ Mac como tráfico exento de Flowlight, así que el proxy decide primero las reglas de destino normales y los paquetes gestionados activados usando
+ la identidad original de la app. Puede hacerlo incluso con túneles que fijan certificados o que nunca se descifran, porque CONNECT nombra el host.
+ Las listas de permitidos de agentes siguen siendo una decisión de la Extensión de red y no se aplican a ese tramo hacia el servidor de origen a
+ través del proxy; el tráfico sin un propietario fiable o sin nombre de host se permite en vez de rechazarse sin que nadie lo vea.
Lo que guarda Flowlight mismo
Metadatos de tráfico en una base de datos SQLite local, con una retención publicada. Con la inspección HTTPS activada guarda además los
diff --git a/site/pages/fr/docs.html b/site/pages/fr/docs.html
index 12fc19cd..124ad2d4 100644
--- a/site/pages/fr/docs.html
+++ b/site/pages/fr/docs.html
@@ -386,6 +386,7 @@
Règles
Packs gérés de publicité et de traqueurs
Règles › Packs contient trois listes officielles de noms d’hôte : Publicité, Suivi intersites et Confidentialité et télémétrie. Elles sont visibles dès le départ et toutes désactivées par défaut. Le blocage nécessite l’Extension réseau ; l’échantillonneur nettop ne peut qu’observer.
Uniquement des mises à jour officielles et signées. Flowlight consulte une source HTTPS fixe unique pour rechercher des catalogues plus récents signés par l’éditeur. Les vérifications automatiques sont activées par défaut et peuvent être désactivées ici. Une signature authentifie le contenu, mais ne l’active pas : un nouveau pack reste désactivé et, si les destinations changent, l’application d’un pack activé est suspendue jusqu’à ce que vous examiniez la nouvelle version exacte et appuyiez sur Enregistrer. Pendant une panne, le contenu accepté peut rester disponible comme dernière version valide connue, visiblement signalée comme obsolète. Il s’agit de blocages réseau au niveau des noms d’hôte : ils ne peuvent pas supprimer les vides dans le DOM ou la mise en page, les superpositions du navigateur, les fenêtres contextuelles générées par des scripts ni les fenêtres d’applications natives.
+
Quand l’inspection HTTPS achemine une app par le proxy de Flowlight, celui-ci décide des règles de destination ordinaires et des packs gérés activés d’après l’identité d’origine de l’app, avant d’ouvrir toute connexion au serveur d’origine. Pour les tunnels CONNECT épinglés ou jamais déchiffrés, cela fonctionne par nom d’hôte ; les listes d’autorisation des agents restent limitées au chemin direct par l’Extension réseau. Si le propriétaire ou le nom d’hôte est inconnu, la connexion est autorisée.
Listes d’autorisation
Sélectionnez un agent dans Agents IA et utilisez Liste d’autorisation dans son détail pour dire où il peut se connecter. Saisissez des domaines
@@ -405,8 +406,7 @@
Refuser des connexions
Flowlight lui-même. Un Mac bloqué est un pire résultat qu’un agent bloqué : en cas de doute, la connexion est donc autorisée et enregistrée.
L’application des refus cesse aussi quelques secondes après l’arrêt de Flowlight — un refus que personne ne peut voir est pire que pas de refus, et le moyen
de l’annuler vit dans l’app.
-
L’inspection HTTPS et le blocage ne se combinent pas : le trafic acheminé par le proxy de Flowlight sort comme trafic de Flowlight, qui n’est
- jamais refusé.
+
Pour une app acheminée par l’inspection HTTPS, le proxy de Flowlight décide des règles de destination ordinaires et des packs gérés activés d’après l’identité d’origine de l’app, avant d’ouvrir toute connexion au serveur d’origine. Pour les tunnels CONNECT épinglés ou jamais déchiffrés, il le fait par nom d’hôte. Les listes d’autorisation des agents restent limitées au chemin direct par l’Extension réseau ; si le propriétaire ou le nom d’hôte est inconnu, la connexion est autorisée.
Garde-fous des agents : bloquer un outil, un serveur ou une ressource
Quels outils cet agent peut-il utiliser est une question différente de quels hôtes peut-il joindre : elle a donc sa propre
diff --git a/site/pages/fr/threat-model.html b/site/pages/fr/threat-model.html
index 587ebe9f..fca0d46c 100644
--- a/site/pages/fr/threat-model.html
+++ b/site/pages/fr/threat-model.html
@@ -87,17 +87,21 @@
Ce que l’inspection manque
des deux s’applique, app par app.
Ce que le blocage est, et n’est pas
-
Une règle qui refuse des connexions est exécutée par l’extension réseau, sur le chemin, avant que la connexion ne soit faite. Une règle qui
- nomme une URL est exécutée par le proxy d’inspection, qui répond à la requête avec un statut que l’app peut lire. Ce sont les deux seules
- parties de Flowlight qui agissent au lieu de rapporter — l’échantillonneur nettop, lui, ne peut rien refuser du tout : sur un Mac qui
- capture avec l’échantillonneur, aucune règle n’est appliquée, et Flowlight le dit plutôt que de proposer un interrupteur qui ne fait rien.
+
Une règle qui refuse des connexions est normalement exécutée par l’extension réseau, sur le chemin, avant que la connexion ne soit faite. Pour
+ le trafic acheminé par l’inspection HTTPS, le proxy local applique les règles de destination et les packs gérés activés au client d’origine
+ avant d’ouvrir la connexion au serveur d’origine ; une règle qui nomme une URL y est également exécutée après le déchiffrement, avec un
+ statut que l’app peut lire. Ce sont les deux seules parties de Flowlight qui agissent au lieu de rapporter — l’échantillonneur nettop, lui, ne
+ peut rien refuser du tout : sur un Mac qui capture avec l’échantillonneur, aucune règle n’est appliquée, et Flowlight le dit plutôt que de
+ proposer un interrupteur qui ne fait rien.
Ce n’est pourtant toujours pas empêcher. Un agent qui peut lancer un shell peut faire à la main ce que l’outil refusé aurait fait. Un refus que
vous pouvez annuler d’un clic est un refus que la tentative suivante d’un agent peut franchir. Le trafic local, le DNS, les services d’Apple et
les connexions de Flowlight lui-même ne sont jamais refusés, parce qu’un Mac bloqué est un pire résultat qu’un agent bloqué. Et l’application
des refus cesse quelques secondes après l’arrêt de Flowlight : un refus que personne ne peut voir est pire que pas de refus.
-
Inspection et refus ne se combinent pas. Le trafic passé par le proxy de Flowlight lui-même quitte le Mac comme du trafic de
- Flowlight, et Flowlight ne se refuse jamais lui-même — une app dont vous lisez les requêtes est donc une app dont les règles n’arrêtent plus
- les connexions. Activer l’inspection pour en voir plus vous coûte l’application des refus, et rien à l’écran ne le disait jusqu’ici.
+
L’inspection préserve le refus par nom d’hôte, avec une limite. Le trafic acheminé par le proxy de Flowlight quitte le Mac comme
+ du trafic Flowlight exempté ; le proxy décide donc d’abord des règles de destination ordinaires et des packs gérés activés en utilisant
+ l’identité d’origine de l’app. Il peut le faire même pour les tunnels épinglés ou jamais déchiffrés, car CONNECT nomme l’hôte. Les listes
+ d’autorisation des agents restent une décision de l’Extension réseau et ne s’appliquent pas à ce trajet vers le serveur d’origine par le proxy ;
+ le trafic sans propriétaire fiable ou sans nom d’hôte est autorisé plutôt que refusé sans que personne ne le voie.
Ce que Flowlight lui-même conserve
Des métadonnées de trafic dans une base de données SQLite locale, avec une durée de conservation publiée. Avec l’inspection HTTPS activée, il
diff --git a/site/pages/it/docs.html b/site/pages/it/docs.html
index 2eeec817..b6bd7cbd 100644
--- a/site/pages/it/docs.html
+++ b/site/pages/it/docs.html
@@ -384,6 +384,7 @@
Regole
Pacchetti gestiti per pubblicità e tracker
Regole › Pacchetti contiene tre elenchi ufficiali di nomi host: Pubblicità, Tracciamento tra siti e Privacy e telemetria. Sono visibili fin dall’inizio e tutti disattivati per impostazione predefinita. Il blocco richiede l’Estensione di rete; il campionatore nettop può solo osservare.
Solo aggiornamenti ufficiali e firmati. Flowlight controlla un’unica fonte HTTPS fissa per trovare cataloghi più recenti firmati dall’editore. I controlli automatici sono attivi per impostazione predefinita e possono essere disattivati qui. Una firma autentica il contenuto, ma non lo attiva: un nuovo pacchetto resta disattivato e, se cambiano le destinazioni, l’applicazione di un pacchetto attivo viene sospesa finché non esamini l’esatta nuova versione e premi Salva. Durante un’interruzione, i contenuti accettati possono restare disponibili come ultima versione valida nota, visibilmente indicata come obsoleta. Si tratta di blocchi di rete a livello di nome host: non possono eliminare spazi vuoti nel DOM o nel layout, sovrapposizioni del browser, popup generati da script o finestre di app native.
+
Quando l’ispezione HTTPS instrada un’app attraverso il proxy di Flowlight, questo decide le normali regole di destinazione e i pacchetti gestiti attivi usando l’identità originale dell’app prima di aprire qualsiasi connessione al server di origine. Per i tunnel CONNECT con pinning o mai decifrati, ciò funziona in base al nome host; gli elenchi consentiti degli agenti restano limitati al percorso diretto attraverso l’Estensione di rete. Se il proprietario o il nome host è sconosciuto, la connessione viene consentita.
Elenchi consentiti
Seleziona un agente in Agenti IA e usa Elenco consentiti nel suo dettaglio per dire dove può connettersi. Inserisci domini
@@ -403,8 +404,7 @@
Rifiutare le connessioni
stesso. Un Mac bloccato è un esito peggiore di un agente bloccato, quindi nel dubbio la connessione viene consentita e registrata.
L'applicazione decade anche pochi secondi dopo che Flowlight smette di funzionare: un rifiuto che nessuno può vedere è peggio di nessun rifiuto, e il modo
per annullarne uno sta nell'app.
-
Ispezione HTTPS e blocco non si combinano: il traffico instradato attraverso il proxy di Flowlight esce come traffico di Flowlight, che non viene
- mai rifiutato.
+
Per un’app instradata attraverso l’ispezione HTTPS, il proxy di Flowlight decide le normali regole di destinazione e i pacchetti gestiti attivi usando l’identità originale dell’app prima di aprire qualsiasi connessione al server di origine. Per i tunnel CONNECT con pinning o mai decifrati, lo fa in base al nome host. Gli elenchi consentiti degli agenti restano limitati al percorso diretto attraverso l’Estensione di rete; se il proprietario o il nome host è sconosciuto, la connessione viene consentita.
Protezioni per gli agenti: bloccare uno strumento, un server o una risorsa
Quali strumenti può usare questo agente è una domanda diversa da quali host può raggiungere, quindi ha una
diff --git a/site/pages/it/threat-model.html b/site/pages/it/threat-model.html
index c1897a3e..82b0377a 100644
--- a/site/pages/it/threat-model.html
+++ b/site/pages/it/threat-model.html
@@ -86,17 +86,21 @@
Cosa sfugge all'ispezione
cose valga, app per app.
Cosa è, e cosa non è, il blocco
-
Una regola che rifiuta le connessioni viene eseguita dall'estensione di rete, sul percorso, prima che la connessione venga stabilita. Una
- regola che nomina un URL viene eseguita invece dal proxy di ispezione, che risponde alla richiesta con uno stato che l'app può leggere. Sono le
- sole due parti di Flowlight che agiscono invece di riferire — il campionatore nettop non può rifiutare proprio nulla, quindi su un Mac che
- acquisisce con il campionatore nessuna regola viene applicata, e Flowlight lo dice invece di offrire un interruttore che non fa niente.
+
Una regola che rifiuta le connessioni viene normalmente eseguita dall'estensione di rete, sul percorso, prima che la connessione venga stabilita.
+ Per il traffico instradato attraverso l'ispezione HTTPS, il proxy locale applica le regole di destinazione e i pacchetti gestiti attivi al client
+ originale prima di aprire la connessione al server di origine; una regola che nomina un URL viene eseguita anche lì, dopo la decifratura, con uno
+ stato che l'app può leggere. Sono le sole due parti di Flowlight che agiscono invece di riferire — il campionatore nettop non può rifiutare
+ proprio nulla, quindi su un Mac che acquisisce con il campionatore nessuna regola viene applicata, e Flowlight lo dice invece di offrire un
+ interruttore che non fa niente.
Non è comunque impedire. Un agente che può eseguire una shell può fare a mano ciò che avrebbe fatto lo strumento rifiutato. Un rifiuto che
puoi annullare con un clic è un rifiuto attraverso cui il tentativo successivo di un agente può passare. Il traffico locale, il DNS, i servizi
di Apple e le connessioni di Flowlight stesso non vengono mai rifiutati, perché un Mac bloccato è un esito peggiore di un agente bloccato. E
l'applicazione decade pochi secondi dopo che Flowlight smette di funzionare: un rifiuto che nessuno può vedere è peggio di nessun rifiuto.
-
Ispezione e rifiuto non si combinano. Il traffico instradato attraverso il proxy di Flowlight stesso lascia il Mac come
- traffico di Flowlight, e Flowlight non rifiuta mai se stesso — quindi un'app di cui stai leggendo le richieste è un'app le cui connessioni le
- regole non fermano più. Attivare l'ispezione per vedere di più ti costa l'applicazione delle regole, e finora nulla sullo schermo lo diceva.
+
L'ispezione mantiene il rifiuto per nome host, con un limite. Il traffico instradato attraverso il proxy di Flowlight lascia il
+ Mac come traffico Flowlight esente, quindi il proxy decide prima le normali regole di destinazione e i pacchetti gestiti attivi usando l'identità
+ originale dell'app. Può farlo anche per tunnel con pinning o mai decifrati, perché CONNECT specifica l'host. Gli elenchi consentiti degli agenti
+ restano una decisione dell'Estensione di rete e non si applicano a quel tratto verso il server di origine attraverso il proxy; il traffico senza
+ un proprietario attendibile o senza nome host viene consentito invece di essere rifiutato senza che nessuno lo veda.
Cosa conserva Flowlight stesso
Metadati del traffico in un database SQLite locale, con una conservazione pubblicata. Con l'ispezione HTTPS attiva conserva anche i corpi delle
diff --git a/site/pages/ja/docs.html b/site/pages/ja/docs.html
index d88f0bb1..e66c611e 100644
--- a/site/pages/ja/docs.html
+++ b/site/pages/ja/docs.html
@@ -355,6 +355,7 @@
규칙 › 팩에는 광고, 사이트 간 추적, 개인정보 보호 및 원격 측정이라는 세 가지 공식 호스트 이름 목록이 있습니다. 처음부터 표시되며 모두 기본적으로 꺼져 있습니다. 차단하려면 네트워크 확장 프로그램이 필요하며, nettop 샘플러는 관찰만 할 수 있습니다.
공식 서명 업데이트만 허용됩니다. Flowlight는 게시자가 서명한 최신 카탈로그가 있는지 하나의 고정 HTTPS 소스를 확인합니다. 자동 확인은 기본적으로 켜져 있으며 여기에서 끌 수 있습니다. 서명은 콘텐츠를 인증하지만 활성화하지는 않습니다. 새 팩은 꺼진 상태로 유지되며, 대상이 변경되면 활성화된 팩은 정확한 새 버전을 검토하고 저장을 누를 때까지 적용되지 않습니다. 서비스 중단 중에도 승인된 콘텐츠가 눈에 띄게 오래된 것으로 표시된 마지막 정상 버전으로 유지될 수 있습니다. 이는 호스트 이름 수준의 네트워크 차단이므로 DOM이나 레이아웃의 빈 공간, 브라우저 오버레이, 스크립트로 생성된 팝업 또는 네이티브 앱 창은 제거할 수 없습니다.
+
HTTPS 검사가 앱을 Flowlight의 프록시로 보내면, 일반 대상 규칙과 활성화된 관리형 팩은 원본 서버 연결이 열리기 전에 원래 앱의 신원을 사용해 결정됩니다. 인증서가 고정되었거나 복호화되지 않는 CONNECT 터널도 호스트 이름으로 판정할 수 있습니다. 에이전트 허용 목록은 계속 네트워크 확장을 직접 지나는 경로에만 적용됩니다. 소유자나 호스트 이름을 알 수 없으면 연결을 허용합니다.
허용 목록
AI 에이전트에서 에이전트를 고르고 상세의 허용 목록에서 어디에 연결할 수 있는지 정하세요.
@@ -399,8 +400,7 @@
연결 거부
트래픽입니다. 막힌 Mac은 막힌 에이전트보다 나쁜 결과이므로, 애매할 때는 연결을 허용하고 기록합니다. 또한 Flowlight가 멈추면
몇 초 뒤에 적용이 풀립니다. 아무도 볼 수 없는 거부는 거부가 없는 것보다 나쁘고, 그것을 되돌리는 방법은 앱 안에 있기
때문입니다.
-
HTTPS 검사와 차단은 함께 쓸 수 없습니다. Flowlight의 프록시를 거친 트래픽은 Flowlight 자신의 트래픽으로 나가는데, 그것은
- 절대 거부되지 않기 때문입니다.
+
HTTPS 검사를 거치는 앱은 Flowlight의 프록시가 원본 서버 연결을 열기 전에 원래 앱의 신원을 사용해 일반 대상 규칙과 활성화된 관리형 팩을 결정합니다. 인증서가 고정되었거나 복호화되지 않는 CONNECT 터널에서는 호스트 이름으로 판정합니다. 에이전트 허용 목록은 계속 네트워크 확장을 직접 지나는 경로에만 적용됩니다. 소유자나 호스트 이름을 알 수 없으면 연결을 허용합니다.
에이전트 가드레일: 도구나 서버, 리소스를 막기
이 에이전트가 어떤 도구를 쓸 수 있나는 어떤 호스트에 닿을 수 있나와 다른 질문이므로 자리도 따로
diff --git a/site/pages/ko/threat-model.html b/site/pages/ko/threat-model.html
index 65af43c2..188dc39c 100644
--- a/site/pages/ko/threat-model.html
+++ b/site/pages/ko/threat-model.html
@@ -86,17 +86,20 @@
검사가 놓치는 것
어느 쪽인지는 포착 범위가 앱마다 알려줍니다.
차단이 무엇이고 무엇이 아닌지
-
연결을 거부하는 규칙을 실행하는 것은 네트워크 확장이며, 그 경로 안에서, 연결이 만들어지기 전에 실행합니다. URL을 지정하는
- 규칙은 검사 프록시가 대신 실행해, 앱이 읽을 수 있는 상태 코드로 요청에 답합니다. 보고하는 대신 실제로 손을 쓰는 Flowlight의
- 부분은 이 둘뿐입니다. nettop 샘플러는 아무것도 거부할 수 없으므로, 샘플러로 돌아가는 Mac에서는 어떤 규칙도 적용되지 않습니다.
- Flowlight는 아무 일도 하지 않는 스위치를 내놓는 대신 그렇다고 말합니다.
+
연결을 거부하는 규칙은 보통 네트워크 확장이 그 경로 안에서, 연결이 만들어지기 전에 실행합니다. HTTPS 검사를 거치는 트래픽은
+ 로컬 프록시가 원본 서버 연결을 열기 전에 원래 클라이언트에 대상 규칙과 활성화된 관리형 팩을 적용합니다. URL을 지정하는 규칙도
+ 복호화 후 그곳에서 실행되어, 앱이 읽을 수 있는 상태 코드로 응답합니다. 보고하는 대신 실제로 손을 쓰는 Flowlight의 부분은 이
+ 둘뿐입니다. nettop 샘플러는 아무것도 거부할 수 없으므로, 샘플러로 돌아가는 Mac에서는 어떤 규칙도 적용되지 않습니다. Flowlight는
+ 아무 일도 하지 않는 스위치를 내놓는 대신 그렇다고 말합니다.
그래도 예방은 아닙니다. 셸을 쓸 수 있는 에이전트는 거부된 도구가 했을 일을 직접 할 수 있습니다. 클릭 한 번으로 되돌릴 수 있는
거부는, 에이전트의 다음 시도가 걸어 나갈 수 있는 거부입니다. 로컬 트래픽과 DNS, Apple 기본 서비스, Flowlight 자신의 연결은 절대
거부되지 않습니다. 막힌 Mac은 막힌 에이전트보다 나쁜 결과이기 때문입니다. 그리고 Flowlight가 멈추면 몇 초 뒤에 적용이 풀립니다.
아무도 볼 수 없는 거부는 거부가 없는 것보다 나쁘기 때문입니다.
-
검사와 거부는 함께 가지 않습니다. Flowlight 자신의 프록시를 거친 트래픽은 Flowlight의 트래픽으로 Mac을
- 떠나고, Flowlight는 자기 자신을 거부하지 않습니다. 그러니 요청을 읽고 있는 앱은, 규칙으로는 더 이상 연결을 막을 수 없는
- 앱입니다. 더 보려고 검사를 켜는 일은 적용을 내주는 일이기도 한데, 화면에는 지금까지 그런 말이 없었습니다.
+
검사에서도 호스트 이름에 따른 거부는 유지되지만 경계가 있습니다. Flowlight의 프록시를 거친 트래픽은 예외 처리된
+ Flowlight 트래픽으로 Mac을 떠나므로, 프록시가 먼저 원래 앱의 신원을 사용해 일반 대상 규칙과 활성화된 관리형 팩을 결정합니다.
+ CONNECT가 호스트 이름을 지정하므로 인증서가 고정되었거나 복호화되지 않는 터널에서도 이 판정을 할 수 있습니다. 에이전트 허용 목록은
+ 네트워크 확장의 결정으로 남으며 프록시에서 원본 서버로 가는 구간에는 적용되지 않습니다. 신뢰할 수 있는 소유자나 호스트 이름이 없는
+ 트래픽은 보이지 않게 거부하지 않고 연결을 허용합니다.
Flowlight 자신이 갖고 있는 것
트래픽 메타데이터를 로컬 SQLite 데이터베이스에, 공개된 보관 기간만큼 갖고 있습니다. HTTPS 검사를 켜 두면 요청과 응답의 본문도
diff --git a/site/pages/pt-PT/docs.html b/site/pages/pt-PT/docs.html
index 26da0a60..8f61b337 100644
--- a/site/pages/pt-PT/docs.html
+++ b/site/pages/pt-PT/docs.html
@@ -386,6 +386,7 @@
Regras
Pacotes geridos de publicidade e rastreadores
Regras › Pacotes contém três listas oficiais de nomes de anfitrião: Publicidade, Rastreio entre sites e Privacidade e telemetria. Estão visíveis desde o início e todas desativadas por predefinição. O bloqueio requer a Extensão de rede; o amostrador nettop apenas pode observar.
Apenas atualizações oficiais e assinadas. O Flowlight consulta uma única fonte HTTPS fixa para procurar catálogos mais recentes assinados pelo editor. As verificações automáticas estão ativas por predefinição e podem ser desativadas aqui. Uma assinatura autentica o conteúdo, mas não o ativa: um novo pacote permanece desativado e, se os destinos forem alterados, um pacote ativo deixa de ser aplicado até que reveja a nova versão exata e prima Guardar. Durante uma interrupção, o conteúdo aceite pode permanecer disponível como a última versão válida conhecida, visivelmente assinalada como desatualizada. Estes são bloqueios de rede ao nível do nome de anfitrião: não podem remover espaços vazios no DOM ou no esquema, sobreposições do navegador, janelas pop-up geradas por scripts ou janelas de aplicações nativas.
+
Quando a inspeção HTTPS encaminha uma app pelo proxy do Flowlight, este decide as regras de destino comuns e os pacotes geridos ativos com base na identidade original da app antes de abrir qualquer ligação ao servidor de origem. Nos túneis CONNECT com certificado fixado ou que nunca são decifrados, isto funciona pelo nome de anfitrião; as listas de permissões dos agentes continuam limitadas ao caminho direto pela Extensão de rede. Se o proprietário ou o nome de anfitrião for desconhecido, a ligação é permitida.
Listas de permissões
Selecione um agente em Agentes de IA e use a Lista de permissões no seu detalhe para dizer onde pode ligar-se. Introduza domínios
@@ -405,8 +406,7 @@
Recusar ligações
Flowlight. Um Mac bloqueado é um resultado pior do que um agente bloqueado, por isso, na dúvida, a ligação é permitida e registada.
A aplicação efetiva também caduca uns segundos depois de o Flowlight deixar de correr — uma recusa que ninguém consegue ver é pior do que nenhuma, e a maneira
de a desfazer está na app.
-
A inspeção HTTPS e o bloqueio não se combinam: o tráfego encaminhado pelo proxy do Flowlight sai como sendo do próprio Flowlight, que
- nunca é recusado.
+
Para uma app encaminhada pela inspeção HTTPS, o proxy do Flowlight decide as regras de destino comuns e os pacotes geridos ativos com base na identidade original da app antes de abrir qualquer ligação ao servidor de origem. Nos túneis CONNECT com certificado fixado ou que nunca são decifrados, fá-lo pelo nome de anfitrião. As listas de permissões dos agentes continuam limitadas ao caminho direto pela Extensão de rede; se o proprietário ou o nome de anfitrião for desconhecido, a ligação é permitida.
Proteções dos agentes: bloquear uma ferramenta, um servidor ou um recurso
Que ferramentas pode este agente usar é uma pergunta diferente de que anfitriões pode alcançar, por isso tem a sua própria
diff --git a/site/pages/pt-PT/threat-model.html b/site/pages/pt-PT/threat-model.html
index 44787410..c3fbe684 100644
--- a/site/pages/pt-PT/threat-model.html
+++ b/site/pages/pt-PT/threat-model.html
@@ -86,17 +86,20 @@
O que escapa à inspeção
coisas se aplica, app por app.
O que o bloqueio é, e não é
-
Uma regra que recusa ligações é levada a cabo pela extensão de rede, no caminho, antes de a ligação ser feita. Uma regra que nomeia um URL é
- levada a cabo pelo proxy de inspeção, que responde ao pedido com um estado que a app consegue ler. São as duas únicas partes do Flowlight que
- agem em vez de relatar — o amostrador nettop não consegue recusar nada, por isso num Mac que capta com o amostrador nenhuma regra é aplicada, e
- o Flowlight di-lo em vez de oferecer um interruptor que não faz nada.
+
Uma regra que recusa ligações é normalmente levada a cabo pela extensão de rede, no caminho, antes de a ligação ser feita. Para o tráfego
+ encaminhado pela inspeção HTTPS, o proxy local aplica as regras de destino e os pacotes geridos ativos ao cliente original antes de abrir a
+ ligação ao servidor de origem; uma regra que nomeia um URL também é aplicada ali, depois da desencriptação, com um estado que a app consegue ler.
+ São as duas únicas partes do Flowlight que agem em vez de relatar — o amostrador nettop não consegue recusar nada, por isso num Mac que capta com
+ o amostrador nenhuma regra é aplicada, e o Flowlight di-lo em vez de oferecer um interruptor que não faz nada.
Continua a não ser impedir. Um agente que consiga executar uma shell pode fazer à mão o que a ferramenta recusada teria feito. Uma recusa
que pode desfazer num clique é uma recusa por onde a tentativa seguinte de um agente pode passar. O tráfego local, o DNS, os serviços da
Apple e as ligações do próprio Flowlight nunca são recusados, porque um Mac bloqueado é um resultado pior do que um agente bloqueado. E a
aplicação efetiva caduca uns segundos depois de o Flowlight deixar de correr: uma recusa que ninguém consegue ver é pior do que nenhuma.
-
A inspeção e a recusa não se combinam. O tráfego encaminhado pelo próprio proxy do Flowlight sai do Mac como tráfego do
- Flowlight, e o Flowlight nunca se recusa a si mesmo — por isso uma app cujos pedidos está a ler é uma app cujas ligações as regras já não
- travam. Ligar a inspeção para ver mais custa-lhe a aplicação das regras, e até agora nada no ecrã o dizia.
+
A inspeção preserva a recusa por nome de anfitrião, com um limite. O tráfego encaminhado pelo proxy do Flowlight sai do Mac como
+ tráfego isento do Flowlight, por isso o proxy decide primeiro as regras de destino comuns e os pacotes geridos ativos com base na identidade
+ original da app. Consegue fazê-lo mesmo em túneis com certificado fixado ou nunca decifrados, porque CONNECT indica o anfitrião. As listas de
+ permissões dos agentes continuam a ser uma decisão da Extensão de rede e não se aplicam a esse trajeto pelo proxy até ao servidor de origem; o
+ tráfego sem um proprietário fiável ou sem nome de anfitrião é permitido em vez de ser recusado sem que ninguém o veja.
O que o próprio Flowlight guarda
Metadados de tráfego numa base de dados SQLite local, com uma retenção publicada. Com a inspeção HTTPS ligada, guarda também os corpos dos
diff --git a/site/pages/releases.html b/site/pages/releases.html
index c04438fc..87a9532f 100644
--- a/site/pages/releases.html
+++ b/site/pages/releases.html
@@ -27,6 +27,9 @@
What's new
Fail closed at startup and on the wire. Capture waits for catalog verification plus saved exceptions;
redirects, MIME, size, timeout, rollback, equivocation, revoked trust, corrupt cache and failed database commits cannot publish
unseen definitions or replace the active snapshot.
+
Keep managed packs enforcing during HTTPS inspection. Before the local proxy opens a CONNECT tunnel or
+ plaintext origin connection, it applies the extension-acknowledged pack snapshot to the original app identity. A refusal is
+ returned only after its activity record commits; failed persistence allows the connection instead of blocking invisibly.
A rule that refuses a connection is carried out by the Network Extension, in the path, before the connection is made. A rule
- that names a URL is carried out by the inspection proxy instead, which answers the request with a status the app can read.
- Those are the only two parts of Flowlight that act rather than report — the nettop sampler can refuse nothing at all, so on a
- Mac running the sampler no rule is enforced, and Flowlight says so rather than offering a switch that does nothing.
+
A rule that refuses a connection is normally carried out by the Network Extension, in the path, before the connection is made.
+ For traffic routed through HTTPS inspection, the local proxy applies destination rules and enabled managed packs against the
+ original client before it opens the upstream connection; a rule that names a URL is also carried out there, after decryption,
+ with a status the app can read. Those are the only two parts of Flowlight that act rather than report — the nettop sampler can
+ refuse nothing at all, so on a Mac running the sampler no rule is enforced, and Flowlight says so rather than offering a switch
+ that does nothing.
It is still not prevention. An agent that can run a shell can do by hand what a refused tool would have done. A refusal you
can undo in one click is a refusal an agent's next attempt may walk through. Local traffic, DNS, Apple's own services and
Flowlight's own connections are never refused, because a blocked Mac is a worse outcome than a blocked agent. And enforcement
lapses a few seconds after Flowlight stops running: a refusal nobody can see is worse than none.
-
Inspection and refusal do not combine. Traffic routed through Flowlight's own proxy leaves the Mac as
- Flowlight's traffic, and Flowlight never refuses itself — so an app whose requests you are reading is an app whose connections
- rules no longer stop. Turning inspection on to see more costs you enforcement, and nothing on screen previously said so.
+
Inspection preserves hostname refusal, with a boundary. Traffic routed through Flowlight's proxy leaves the
+ Mac as exempt Flowlight traffic, so the proxy decides ordinary destination rules and enabled managed packs first, using the
+ original app identity. It can do this even for pinned or never-decrypted tunnels because CONNECT names the host. Agent allowlists
+ remain a Network Extension decision and do not apply to that proxied upstream leg; traffic with no trustworthy owner or no
+ hostname fails open rather than being refused invisibly.
What Flowlight itself holds
Traffic metadata in a local SQLite database, with published retention. With HTTPS inspection on it also holds request and
diff --git a/site/pages/zh-Hans/docs.html b/site/pages/zh-Hans/docs.html
index 2c871008..e39d0f16 100644
--- a/site/pages/zh-Hans/docs.html
+++ b/site/pages/zh-Hans/docs.html
@@ -349,6 +349,7 @@