id: ADR-014 title: Las instancias de plugins solo acceden a la red a través de una única autoridad de salida gestionada por el host date: 2026-08-20 status: propuesto relates-to:
- ADR-006
- ADR-009
- https://github.com/zeroclaw-labs/zeroclaw/issues/9395
- https://github.com/zeroclaw-labs/zeroclaw/issues/8850
- https://github.com/zeroclaw-labs/zeroclaw/issues/8398
- https://github.com/zeroclaw-labs/zeroclaw/pull/9580
- https://github.com/zeroclaw-labs/zeroclaw/pull/9137
- https://github.com/zeroclaw-labs/zeroclaw/pull/9582
- https://github.com/zeroclaw-labs/zeroclaw/pull/9584
- https://github.com/zeroclaw-labs/zeroclaw/pull/11098
- https://github.com/zeroclaw-labs/zeroclaw/issues/10996
- https://github.com/zeroclaw-labs/zeroclaw/pull/9126
- crates/zeroclaw-infra/src/net_guard.rs
- crates/zeroclaw-plugins/src/egress.rs
- crates/zeroclaw-plugins/src/component.rs
- crates/zeroclaw-config/src/schema.rs
- crates/zeroclaw-tools/src/helpers/domain_guard.rs
ADR-014: Las instancias de plugins solo acceden a la red a través de una única autoridad de salida (egress) controlada por el host
Contexto
Antes de que se publicara la aplicación de la política en #9582, ZeroClaw vinculaba la importación wasi:http en un almacén de plugins cuando el manifiesto del plugin solicitaba el permiso http_client. Ese almacén instalaba los hooks de solicitud predeterminados de wasmtime (crates/zeroclaw-plugins/src/component.rs), por lo que nada en el lado de ZeroClaw decidía a qué destinos podía conectarse el código invitado: no existía una lista de destinos permitidos, ni una protección por clase de dirección, ni una configuración accesible para el operador. Los permisos del manifiesto eran solicitudes, y el host concedía cada una tal cual (crates/zeroclaw-plugins/src/instance.rs), mientras que plugins.security.signature_mode tenía como valor predeterminado disabled. Por tanto, un componente sin firmar podía declarar http_client, recibirlo y convertirse en un pivote sin filtrar hacia servicios de loopback, la pasarela local o direcciones de metadatos de la nube. El fallo #9395 documenta esa consecuencia histórica; la versión actual aplica la política de destinos gestionada por el host.
El componente hermano del lado del host ya deniega por defecto. La herramienta http_request rechaza toda llamada cuando su lista allowed_domains está vacía, y valida el destino y cada dirección resuelta frente a los rangos de metadatos, loopback y privados mediante crates/zeroclaw-tools/src/helpers/domain_guard.rs y crates/zeroclaw-infra/src/net_guard.rs. Dos rutas de salida en un mismo proceso ofrecían garantías opuestas.
La presión sobre este límite aumenta con ADR-006, que convierte a los plugins de runtime en el destino de los canales opcionales, porque cada canal de mensajería es un consumidor de egress. Hoy wit/v0/inbound.wit establece claramente que un plugin de canal se ejecuta sin red y sin sockets, y el repositorio oficial de plugins contiene un borrador de interfaz de sockets en wit/unstable/sockets.wit para un transporte que el host no expone. El próximo transporte que llegue necesitará una respuesta ya existente a «¿puede esta instancia alcanzar esa dirección?», no una tercera.
Parte de esta dirección ya se ha implementado. #9580 trasladó el egreso HTTP integrado a un guardia de red compartido, #9137 añadió la base de la política de egreso del lado del plugin, #9582 aplicó la política en el límite de wasi:http y #9126 añadió la configuración tipada de instancias. El proceso de concesión por parte del operador se incorporó para la instalación y el listado en #11098, que reemplazó a #9584, y para los enlaces de canal mediante zeroclaw plugin bind bajo #10996. Este registro establece la decisión que implementan y completan esos fragmentos. La RFC #8398 está cerrada como RFC omnibus/reemplazada; este ADR registra la decisión focalizada sobre el egreso de red que sobrevivió a esa división: Q1 para permisos de red y Q4 para concesiones de destino extendidas por el usuario.
Las alternativas son tratar la declaración en el manifiesto como la concesión en sí, mantener una única lista de permitidos global para todos los plugins instalados, o dar a cada transporte su propia política de destinos. Tratar el manifiesto como concesión mantiene la ruta de autoconcesión #9395 para paquetes sin firmar, viola la doctrina de cierre por defecto y convierte al manifiesto en una segunda fuente de verdad para la autoridad activa. Una lista de permitidos global impide el aislamiento por instancia, porque cualquier plugin instalado podría entonces alcanzar cualquier host que necesite otro plugin. Las políticas por transporte introducen tres controles para una misma pregunta y favorecen las divergencias, cuando la decisión sobre el destino es independiente del transporte.
Decisión
Use una única autoridad de salida con mecanismos de política compartidos y denegación por defecto
Todo el tráfico de red saliente de los plugins, hoy wasi:http y cualquier futura importación de socket, WebSocket o perfil TLS, se canaliza a través de una única autoridad de salida gestionada por el host. La coincidencia de destinos, la clasificación de direcciones, la traducción NAT64 y el veredicto posterior a la resolución residen en zeroclaw-infra::net_guard, que ya utilizan las herramientas integradas. crates/zeroclaw-plugins/src/egress.rs contiene el servicio con alcance de instancia que consume esas primitivas y no reimplementa ninguna de ellas, y zeroclaw-plugins no depende de zeroclaw-tools para esto. Un plugin y una herramienta integrada no deben poder discrepar sobre si un destino es accesible.
A plugin instance with no granted destination has no network reach. There is no compatibility mode in which http_client alone confers unrestricted HTTP. The permission grants the surface; the operator’s list grants the destinations.
El servicio vuelve a verificar el permiso requerido por el transporte en el límite de la operación, aunque el enlazador solo expone las importaciones que se concedieron a una instancia admitida. La comprobación duplicada es una defensa en profundidad deliberada: un adaptador de transporte no debe poder convertir una importación enlazada pero no concedida en acceso a la red.
Aplicar en el límite de wasi:http con una ruta de envío fijada
La aplicación de políticas reside en el límite de transporte. Una implementación de hooks de ZeroClaw almacenada por cada almacén de plugins reemplaza los hooks predeterminados de wasmtime, y su hook de solicitud evalúa la política en cada solicitud emitida por el invitado. La ruta de envío es propiedad del host y está fijada: resuelve el destino una sola vez, valida las direcciones resueltas mediante el guardia compartido y luego conecta únicamente a esas direcciones exactas, usando el nombre de host para SNI y la verificación del certificado. Una respuesta DNS no puede cambiar de clase entre la comprobación y la conexión, porque no hay una segunda resolución. ResolvedDestination existe para que esa propiedad sea estructural: conserva las direcciones validadas y no ofrece ninguna vía de regreso a una nueva consulta.
The host never follows redirects on a guest’s behalf. A guest that chooses to chase a redirect issues a new request, and every hop passes the full policy independently.
Wait, I need to correct myself: the source is in English, so I should translate it to Spanish. Here is the translation:
El host nunca sigue redirecciones en nombre de un invitado. Un invitado que decide seguir una redirección emite una nueva solicitud, y cada salto pasa la política completa de forma independiente.
Una denegación devuelve al invitado un error enmascarado que menciona la política en lugar de los detalles internos del host, y emite un evento de registro estructurado en el host que atribuye el intento a la instancia exacta.
Esta modificación se incorporó en #9582 para wasi:http; las puertas restantes cubren la intersección, la cobertura de los fixtures y el despliegue en la flota.
Que el manifiesto declare y que la configuración del operador conceda
Un manifiesto de plugin puede declarar los destinos que el paquete necesita en una sección [egress]: hosts exactos o patrones de sufijo explícitos. Esa declaración es una solicitud cubierta por la firma, una declaración de intención que viaja con el paquete. Nunca se consulta como concesión en el momento de la solicitud.
El permiso se almacena en la configuración canónica del operador, como campos de texto plano en la fila [[plugins.entries]] de la instancia: egress_hosts para los destinos y egress_allow_private para la excepción de clase de dirección. Ambos son elementos hermanos deliberados del mapa config marcado con #[secret], y nunca están dentro de él, porque la lista de permitidos es lo que el operador audita y debe seguir siendo legible en el archivo que revisa.
El alcance efectivo de una instancia es la intersección de lo que solicita su manifiesto y de lo que concedió el operador. Una declaración, por sí sola, no otorga nada, y una concesión del operador no puede extenderse más allá de lo que el paquete declaró necesitar. Hay dos vías de concesión de primera clase:
- Sembrado a partir de la declaración durante la ceremonia de concesión que aparece a continuación.
- Redactado directamente por el operador, que es la única vía posible para un plugin cuyo destino es en sí mismo configuración de la instancia, como un Gitea autoalojado, un homeserver de Matrix o un Nextcloud en la LAN. El autor no puede conocer el host, así que el operador que lo configura es quien lo concede.
La política se lee de la configuración canónica en cada solicitud, en lugar de guardarse como instantánea en el estado de la instancia, de modo que la edición de un operador se aplica a la siguiente conexión y no al siguiente reinicio. ADR-012, todavía en estado propuesto, describe el modo de aplicación relacionado limitado por generación; aquí sirve como contexto, no como autoridad.
Los campos del operador, la lectura por solicitud y la declaración [egress] del manifiesto están combinados. La intersección aún no está implementada, por lo que en esta versión solo rige la concesión del operador; la puerta G1 la cubre.
Mantén la gramática de destino libre de una forma de permitir todo
Las entradas son hosts exactos o patrones *.suffix explícitos. Un dominio simple nunca implica a sus subdominios, un patrón de sufijo nunca coincide con el dominio apex, y la ausencia de entradas significa “en cualquier lugar”. La validación rechaza un * sin más, y uno no validado solo coincidiría con un host llamado literalmente *, así que incluso una lista que hubiera eludido la validación falla en modo restrictivo. Un host expresado como dirección IP solo coincide con una entrada idéntica exacta, y nunca mediante un patrón de sufijo, porque las direcciones no tienen estructura de subdominios y tratar una como nombre con puntos permitiría que *.0.0.1 coincidiera con 127.0.0.1. Una lista vacía no coincide con nada. Los puertos no forman parte de una entrada: permitir un host concede todos los puertos de este.
Mantener las clases de direcciones bloqueadas por encima de la lista de permitidos
Las direcciones de metadatos de la nube se rechazan incondicionalmente en ambos modos de acceso, por lo que una activación explícita por parte del operador para destinos privados nunca las vuelve a habilitar. Los destinos de loopback, link-local y de rangos privados se rechazan incluso cuando coincide un patrón, a menos que la entrada incluya ese host en egress_allow_private. La excepción solo relaja la clase de dirección: un host incluido allí pero no en egress_hosts sigue denegado, y replica la semántica de allowed_private_hosts que ya usan las herramientas integradas, de modo que ambos consumidores mantengan un mismo vocabulario.
Un conjunto de respuestas que abarca ambas zonas de confianza se rechaza en lugar de reducirse a una de ellas, incluso cuando el acceso privado está autorizado. De lo contrario, el orden del resolutor o el mecanismo de reserva de conexión decide silenciosamente en qué zona termina la conexión.
Hacer que los operadores declaren los prefijos NAT64 y que una lista malformada falle en modo fail-closed
Un traductor NAT64 entrega cualquier destino IPv6 dentro de su prefijo configurado a la dirección IPv4 incrustada en ese destino. El prefijo es una decisión de despliegue y nada en la dirección lo revela, por lo que un atacante que controla la respuesta DNS de un nombre de host puede devolver una dirección IPv6 aparentemente global que el traductor local entrega a 10.0.0.1 o 169.254.169.254. security.nat64_prefixes es donde los operadores declaran las traducciones que su red realmente utiliza. Las entradas tienen el formato <ipv6>/<length> con las longitudes de la sección 2.2 de RFC 6052, las seis, y sin bits activos más allá del prefijo.
Se aplican dos reglas, y ambas son más estrictas que la implementación obvia. Primero, una entrada incorrecta rechaza la lista completa en lugar de reducirla al subconjunto bien formado, porque una lista que se analizó como “ningún prefijo” es idéntica a un despliegue que no ejecuta ningún traductor y desactivaría la clasificación específica de red sin ninguna señal. Segundo, los prefijos declarados pueden solaparse, y una dirección puede entonces decodificarse a un destino IPv4 distinto según cada uno; todos esos destinos son accesibles, así que una dirección solo se acepta cuando cada traducción declarada que coincide con ella da como resultado un destino aceptable, y no cuando lo hace la primera que resulte aceptable.
Haz que la concesión sea una ceremonia explícita
zeroclaw plugin install imprime los destinos declarados de un paquete y los incorpora a las entradas que ya crea. Una instancia cuyo enlace se crea más adelante, como un alias de canal, recibe su entrada de salida cuando se crea ese enlace, a partir de la misma declaración y con la misma impresión. La instalación y la creación de enlaces son actos explícitos del operador, y la lista de permitidos impresa y persistida es su registro. zeroclaw plugin list muestra los destinos concedidos de cada instancia, para que el alcance pueda auditarse sin leer el archivo de configuración.
Una actualización de paquete cuya declaración añade destinos no amplía una entrada existente. La CLI muestra la diferencia y el operador la aplica de forma deliberada. Si no existe una entrada, el tráfico saliente queda denegado.
La precarga durante la instalación, la comparación de la actualización y el informe correspondiente de zeroclaw plugin list se incorporaron en #11098, que reemplazó a #9584. La precarga durante el enlace se incorporó con zeroclaw plugin bind en #10996, y zeroclaw plugin install --channel-alias ejecuta el mismo procedimiento durante una instalación. Como el operador indica el alias en ese momento, el procedimiento de enlace toma la concesión como una elección explícita: --egress declared precarga la declaración con la misma salida impresa que muestra la instalación, --egress none crea la entrada con una concesión vacía, y sin ninguno de los dos indicadores, un enlace que crearía la entrada a partir de una declaración no vacía se rechaza. Ninguno de los dos comandos extiende nunca una entrada existente.
Vincular los transportes futuros al mismo objeto de política
Las importaciones de Socket, WebSocket y de perfil TLS, cuando el host las adopta, consumen esta misma política por instancia. Una conexión de socket o una actualización a WebSocket hacia un host y un puerto pasa por la misma lista de permitidos y las mismas reglas de clase de dirección, y un perfil TLS selecciona el material de confianza, pero nunca omite la política de destino. Una importación de transporte que no pueda enrutar su decisión de destino a través de este servicio no se incorpora.
Los canales TCP sin procesar quedan fuera del alcance de este registro hasta que exista una capacidad de sockets WASI en el host. Mientras tanto, un canal cuyo protocolo no sea HTTP no tiene ruta del lado del plugin y sigue siendo nativo según la regla de excepción de capacidades de ADR-006. Matrix es una de esas excepciones, y este registro no la retira: el cifrado de extremo a extremo es la capacidad del host que falta de forma explícita, y la implementación nativa se mantiene hasta que el límite del plugin pueda soportarla. Se trata de una cuestión de ADR-006 sobre la paridad de capacidades, y nada de lo aquí expuesto la modifica.
Una concesión es independiente del protocolo. Un operador que concede un host lo concede para cualquier transporte que ya permitan los permisos de esa instancia, por lo que no existe un eje separado para el texto plano ni un campo egress_allow_plaintext. La confidencialidad es el contrato del transporte con su par, no una segunda lista de destinos. Cuando un protocolo negocia TLS en banda, el host controla la transición de fase y nunca permite un respaldo a texto plano una vez iniciada la actualización.
Los presupuestos de conexiones por instancia pertenecen a la base y no a cada transporte. plugins.limits.max_connections_per_instance se comparte entre todos los transportes y todos los almacenes que pertenecen a una misma instancia lógica, y una solicitud autorizada mantiene un arrendamiento durante toda la vida de su conexión.
Limitar la entrada de manifiesto no confiable en el mismo límite
Un manifiesto es una entrada controlada por el atacante en el mismo límite de confianza que un destino de salida (egress), por lo que los bytes baratos del manifiesto no deben poder costar trabajo caro en el host. El trabajo tipado de configuración de instancias incluido en #9126 limita la validación de patrones en los esquemas suministrados por el manifiesto en consecuencia: los patrones se compilan en un motor de tiempo lineal con un límite explícito de 256 KiB para el programa compilado y un límite de 1 MiB para el DFA, y un patrón que supere cualquiera de los dos se rechaza como manifiesto inválido en lugar de compilarse. Rechazar el paquete es la dirección segura, porque un autor de plugin que quiera un patrón tan costoso puede escribir uno más pequeño, mientras que un host que ya ha empezado a compilarlo no puede recuperar su planificador. Se registra aquí porque refuerza el límite del que es responsable este ADR, no porque esté en la ruta de salida.
Controles de aceptación
Este ADR permanece propuesto hasta que se cumplan todas estas condiciones:
- las primitivas de protección compartidas residen en
zeroclaw-infra::net_guardcon ambos consumidores conectados a ellas, los hooks por almacén y la ruta de envío fijada (pinned) se entregan para los almacenes de plugins, la declaración[egress]del manifiesto existe con análisis, validación y cobertura de firma, la concesión efectiva es la intersección de esa declaración con la entrada del operador, y la siembra en el momento de la instalación y en el momento de la vinculación, así como la ceremonia de comparación de diferencias en las actualizaciones, funcionan (G1); - El CI obligatorio demuestra el límite con un fixture de componente real: denegado por defecto sin entrada, permitido mediante una entrada precargada, rechazo de metadatos y direcciones privadas sobre una lista de permitidos coincidente, y un componente que sigue una redirección desde un host permitido hacia una clase bloqueada tiene su segunda solicitud denegada (G2);
- el primer plugin de canal seleccionado por #8850 se ejecuta con una entrada precargada para su host de API (G3); y
- El despliegue para la flota existente está completo: los paquetes del registro oficial
http_clientincluyen declaraciones[egress]en las versiones republicadas antes de que se active la aplicación en el host. Un diagnóstico durante la actualización enumera los destinos denegados de cada instancia instalada junto con el comando exacto de siembra. La versión que habilita la aplicación documenta la ruptura en su changelog, y #9395 ya está cerrado por el slice de aplicación (G4).
La guarda compartida, los campos de operador en texto plano, la lectura de la política por solicitud, la gramática estricta de destinos, el límite de NAT64, el presupuesto de conexiones, la declaración [egress] del manifiesto, y la carga inicial en el momento de la instalación y del enlace con el diff de actualización están implementados. El resto de G1, la intersección de esa declaración con la entrada del operador, no lo está, como tampoco G2 a G4.
Consecuencias
Consecuencias positivas:
- El cambio #9395 se cierra por construcción y no solo mediante una política de firmas, porque un componente sin firmar o malicioso no puede convertir una autosolicitud del manifiesto en alcance de red.
- Los operadores disponen de un único parámetro de texto plano, auditable, por instancia, y una modificación se aplica a la siguiente conexión sin volver a instanciar el invitado.
- Una actualización de paquete no puede ampliar el tráfico de salida de forma silenciosa.
- Las herramientas integradas y los plugins convergen en un mismo vocabulario de guardas en lugar de divergir entre sí.
- Un nuevo transporte hereda la decisión de destino en lugar de volver a abrirla.
Consecuencias negativas:
- Este es un cambio incompatible para todo plugin
http_clientya instalado: hasta que su entrada sea inicializada o definida, no tiene tráfico saliente. G4 existe por este motivo, y el cambio solo se distribuye cuando ya están disponibles el diagnóstico de migración y las declaraciones republicadas. - El ceremonial añade un paso en el momento de la instalación y otro en el momento de la vinculación, además del estado de política por entrada.
- Los plugins con necesidades de destino dinámicas, como URLs suministradas por el usuario, no obtienen egreso comodín. Declaran patrones de sufijo que el operador acepta, dependen de entradas creadas por el operador o esperan a un servicio de obtención mediado por el host. Ese es el costo deliberado de la respuesta a la Q4 de #8398.
- Los despliegues detrás de un traductor NAT64 deben declarar sus prefijos para mantener precisa la frontera, y una evaluación conservadora de prefijos superpuestos puede rechazar un destino que el operador considera legítimo.
- La lectura de la política se realiza por solicitud y no por instanciación, por lo que el resolutor de configuración está en la ruta de la conexión.
Referencias
- Bug #9395: el plugin
wasi:httpde salida no tiene política de destino ni opción de configuración - Migration tracker #8850
- RFC #8398: modelo de permisos, configuración y secretos de plugins (contexto cerrado/reemplazado)
- PR #9580: reforzar el egreso HTTP integrado en el guardián de red compartido (fusionado)
- PR #9137: base compartida de política de egreso (fusionado)
- PR #9582: aplicar una política de salida de red (egress) propiedad del host en el plugin
wasi:http(fusionado) - PR #9584: ceremonia de concesión de egress para la instalación y el listado de plugins (cerrado, reemplazado por #11098)
- PR #11098: conceder egress durante la instalación e informar si un plugin se carga (fusionado)
- Issue #10996: sembrar la configuración de instancias de canal y los permisos](https://github.com/zeroclaw-labs/zeroclaw/issues/10996) (siembra en el momento de la vinculación)
- PR #9126: validación de configuración de instancia tipada (fusionado)
- ADR-006: Plugins de canal en tiempo de ejecución
- ADR-009: Ejecución de plugins WIT y wasmtime
- ADR-012: Aplicación de configuración en vivo con alcance por generación
- Modelo de seguridad
crates/zeroclaw-infra/src/net_guard.rscrates/zeroclaw-plugins/src/egress.rscrates/zeroclaw-plugins/src/component.rscrates/zeroclaw-config/src/schema.rscrates/zeroclaw-tools/src/helpers/domain_guard.rscrates/zeroclaw-tools/src/http_request.rs