@aimarket/warden 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,224 @@
1
+ # La cadena de puertas
2
+
3
+ > 🌐 [English](gates.md) · [Русский](gates.ru.md) · **Español** · [Français](gates.fr.md) · [中文](gates.zh.md)
4
+
5
+ > [Seguridad y migración de 0.7.0](security-hardening.es.md).
6
+
7
+ `Warden.vet(server, tools)` ejecuta una cadena ordenada y devuelve un solo veredicto. Esta página es
8
+ todo el procedimiento de decisión: qué mira cada puerta, qué puede bloquear y cómo se construye el
9
+ número final.
10
+
11
+ ```
12
+ static-scan → threat-feed → origin → pinning
13
+ (gratis) (gratis tras (gratis) (gratis)
14
+ load)
15
+ ```
16
+
17
+ El orden va de lo más barato y local primero. Nada en la cadena hace una petición de red: la única
18
+ descarga que WARDEN llega a hacer es `ThreatFeed.load(url)`, que llamas tú, antes de examinar nada.
19
+
20
+ ## Cómo se ensambla un veredicto
21
+
22
+ Cada puerta devuelve `{ findings, score, fatal? }`. La cadena:
23
+
24
+ 1. ejecuta todas las puertas en orden, acumulando hallazgos (cada puerta ve `prior`);
25
+ 2. multiplica las puntuaciones de las puertas — la compuesta es un **producto**, así que una puerta
26
+ mala arrastra al servidor hacia abajo en vez de quedar promediada por tres buenas;
27
+ 3. bloquea si alguna puerta devolvió `fatal`, o si algún hallazgo no advisory alcanza
28
+ `policy.blockAtSeverity`;
29
+ 4. corta la cadena **solo** ante un `fatal` explícito. Un hallazgo bloqueante pero no fatal deja que
30
+ las puertas restantes informen, para que el registro del *por qué* quede completo.
31
+
32
+ ```ts
33
+ const SEVERITY_RANK = { info: 0, low: 1, medium: 2, high: 3, critical: 4 };
34
+ ```
35
+
36
+ Si `policy.blockAtSeverity` no es una de esas cinco claves, el constructor registra un aviso y cae a
37
+ `"high"`. Una errata ahí era antes el peor fallo posible: `rank >= undefined` es `false` en toda
38
+ comparación, de modo que un umbral mal escrito desactivaba el bloqueo por completo, en silencio.
39
+
40
+ ### Dos ejes: severidad y nivel
41
+
42
+ La severidad responde a *cuánta atención merece esto*. El **nivel** (tier) responde a *¿es esto un
43
+ defecto?* — y es un dato del hallazgo (`advisory: true`), no una consecuencia de la severidad.
44
+
45
+ Un hallazgo `advisory` se reporta, nunca bloquea y nunca cuesta una herramienta, con **cualquier**
46
+ `blockAtSeverity`. Una herramienta cuyo esquema acepta un `api_key` merece que se la señale y no es un
47
+ defecto; expresarlo bajando su severidad la volvía bloqueante para quien endureciera el umbral.
48
+
49
+ ## static-scan
50
+
51
+ Escaneo local con regex sobre el `name`, la `description` y el `inputSchema` de cada herramienta. 26
52
+ reglas en el conjunto **v7**: 15 `block`, 11 `advise`, y 15 de ellas llevan un **guard** de contexto:
53
+ una comprobación con nombre que decide si una coincidencia es de verdad lo que la regla busca. Véase
54
+ [el estudio de campo](mcp-survey.es.md), la ejecución sobre 1 108 servidores con la que se calibró v4.
55
+
56
+ **v5: el texto se normaliza antes de que lo lea ninguna regla.** NFKC convierte letras de ancho completo,
57
+ ligaduras y otras formas de compatibilidad en letras normales; los caracteres invisibles dentro de una
58
+ palabra se eliminan; el bloque de etiquetas Unicode (copias invisibles de ASCII capaces de llevar una frase
59
+ entera) se decodifica; y dentro de una palabra que mezcla latino con cirílico o griego, las letras sosias
60
+ pasan a latinas, mientras que una palabra escrita entera en un solo alfabeto no se toca. Una regla escrita
61
+ en inglés ya no se elude con letras de ancho completo, un espacio de ancho cero, etiquetas o una `о`
62
+ cirílica, sea cual sea el idioma del texto. La normalización se publica como `fold` y forma parte del
63
+ digest. Las dos reglas de cargas ocultas leen el texto **en bruto** (`raw: true`).
64
+
65
+ Una tabla de regex no lee significado: una frase en un idioma para el que las reglas no están escritas queda
66
+ fuera. v5 añade lo que no depende del idioma: la normalización, el par `TOOL_DEF_SECRET_EXFIL` (un almacén de
67
+ secretos y una dirección externa a menos de 100 caracteres; solo de aviso, porque su única coincidencia en
68
+ 10 645 servidores reales era honesta), el bloque de etiquetas y los aislantes bidi en `TOOL_DEF_HIDDEN_UNICODE`;
69
+ y HISTOR señala toda dirección externa que aparece por primera vez. Detectar por significado corresponde a un
70
+ clasificador, no a esta tabla.
71
+
72
+ v5 también elimina tres falsos positivos medidos: «send the user to https://…» (redirigir a una persona, guard
73
+ `navigation`), «keep calling … without asking the user» (autonomía, guard `autonomy`) y el unificador de ancho
74
+ cero dentro de un emoji. Sobre el corpus de 10 645 servidores v5 bloquea 56 donde v4 bloqueaba 63, y ninguno
75
+ que v4 no bloqueara.
76
+
77
+ **v7** elimina tres falsos positivos que el conjunto v6 aún daba sobre el corpus versionado del
78
+ 2026-10-01 ([el estudio de campo](mcp-survey.es.md)). «Private key/value memory» nombra un almacén
79
+ clave-valor, no una clave privada (guard `keyValue`: un compuesto con barra o guion seguido de un
80
+ sustantivo de almacén). «Find … without asking the user for ids» es una herramienta que resuelve sola
81
+ un identificador (guard `autonomy`, que ahora también acepta un verbo de búsqueda cuando todo el
82
+ objeto es un identificador). «The key is read from the MCP connection's X-API-Key header» es un
83
+ servidor que describe su propia autenticación (guard `ownAuthHeader`: pasiva «is read from», una
84
+ cabecera de la petición, no ajena, y la clave no se envía a ninguna parte). Además, `autonomy` ya no
85
+ exime un «without asking the user» cuyo objeto es el consentimiento: «keep retrying the transfer
86
+ without asking the user for approval» vuelve a bloquear. Sobre ese corpus v7 bloquea 3 servidores
87
+ donde v6 bloqueaba 6, y el resto de hallazgos no cambia.
88
+
89
+ Cada regla declara sobre cuál de esas tres **superficies** se ejecuta, y 17 de las 26 incluyen el
90
+ nombre. Las cuatro que no lo hacen son las que se apoyan en un SUSTANTIVO
91
+ (`TOOL_DEF_SECRET_REQUEST`, `TOOL_DEF_CREDENTIAL_PARAM`, `TOOL_DEF_ENV_REFERENCE`, `TOOL_DEF_SECRET_EXFIL`): un nombre es un
92
+ identificador, `api_key` y `private_key` son partes ordinarias de uno, y rechazar
93
+ `sign_with_private_key` sería cometer el error de calibración de v1 en una superficie nueva. Las
94
+ reglas que se apoyan en una FRASE necesitan espacios y no pueden coincidir con `snake_case` en
95
+ absoluto, y las dos reglas de carga oculta hablan de caracteres que nunca son legítimos en un
96
+ nombre: esas se ejecutan en todas partes.
97
+
98
+ Hasta v3 el nombre no lo escaneaba **nada**, así que una frase de inyección, un carácter de ancho
99
+ cero o un blob base64 en el primer campo que lee el modelo no se reportaban en absoluto.
100
+
101
+ La puntuación de la puerta es `1 − penalización(peor severidad bloqueante)`; los avisos nunca la
102
+ afectan.
103
+
104
+ | peor severidad bloqueante | ninguna | info | low | medium | high | critical |
105
+ |---|---|---|---|---|---|---|
106
+ | puntuación | 1 | 1 | 0.9 | 0.7 | 0.4 | 0 |
107
+
108
+ | Código | Severidad | Nivel | ¿Nombre? | Qué detecta |
109
+ |---|---|---|---|---|
110
+ | `TOOL_DEF_INJECTION` | critical / high | block | ✅ | «ignore all previous instructions», «do not tell the user», etiquetas `<system>`, referencias al prompt del desarrollador |
111
+ | `TOOL_DEF_SECRET_REQUEST` | critical | block | — | `private_key`, `seed_phrase`/`mnemonic`, rutas `~/.ssh` |
112
+ | `TOOL_DEF_SECRET_HARVEST` | critical | block | ✅ | una herramienta cuyo cometido declarado es leer/volcar/revelar secretos |
113
+ | `TOOL_DEF_EXFIL` | critical / high | block | ✅ | «post to https://…», «forward it to…», «exfiltrate», fraseo de subida a un host |
114
+ | `TOOL_DEF_HIDDEN_UNICODE` | high | block | ✅ | caracteres de ancho cero y de control bidi — texto que el revisor no ve |
115
+ | `TOOL_DEF_BASE64_BLOB` | high | block | ✅ | una tirada base64 de 120+ caracteres en un nombre, una descripción o un esquema |
116
+ | `TOOL_DEF_DATA_URL` | high | block | ✅ | URLs `data:…;base64,` y `javascript:` |
117
+ | `TOOL_DEF_CREDENTIAL_PARAM` | medium / low | advise | — | esquema o descripción que pide `api_key`, `password`, `secret`, tokens bearer |
118
+ | `TOOL_DEF_ENV_REFERENCE` | medium | advise | — | `.env`, «environment variables» |
119
+ | `TOOL_DEF_IMPERATIVE` | low / info | advise | ✅ | «you must», «instead of» — fraseo con forma de prompt, que por sí solo no prueba nada |
120
+
121
+ `staticScanRuleset()` devuelve cada regla con **el fuente de su regex, sus flags y sus superficies**, para que un
122
+ tercero pueda reejecutar exactamente la misma regla, más `{ version, digest }`, donde el digest es
123
+ sha256 sobre la forma canónica RFC 8785 de la lista ordenada de reglas. La ordenación es por
124
+ comparación de unidades de código, nunca `localeCompare`: una collation dependiente del locale haría
125
+ que la misma tabla produjera un digest distinto en un host configurado de otra forma, que es justo la
126
+ divergencia que el digest existe para detectar.
127
+
128
+ ## threat-feed
129
+
130
+ Compara la identidad del servidor y las definiciones de herramientas con registros `ThreatRecord` — 11
131
+ integrados más lo que haya añadido un feed firmado (ver [el contrato del feed](threat-feed.es.md)).
132
+
133
+ - Cualquier coincidencia ⇒ puntuación de la puerta **0**.
134
+ - `fatal` **solo** para un registro `critical` que coincide contra el *servidor*. Una coincidencia
135
+ crítica en una *herramienta* no es fatal, así que el resto de la cadena sigue informando y la culpa
136
+ queda circunscrita a esa herramienta — eso es lo que permite que un servidor casi correcto siga
137
+ funcionando con una herramienta en cuarentena.
138
+ - `ThreatRecord.scope` elige la superficie: `server` (id/name/url/command/args), `tool`
139
+ (name/description/inputSchema) o `any` — el valor por defecto cuando el registro lo omite.
140
+
141
+ Códigos integrados: `THREAT_TYPOSQUAT`, `THREAT_CRYPTO_DRAINER`, `THREAT_SEED_PHRASE`,
142
+ `THREAT_SSH_KEY_READ`, `THREAT_ENV_EXFIL`, `THREAT_DESTRUCTIVE_CMD`, `THREAT_FORK_BOMB`.
143
+
144
+ ## origin
145
+
146
+ ¿Declaró el operador este servidor, o llegó desde un catálogo remoto (`McpServerRef.catalog` está
147
+ puesto)?
148
+
149
+ | `allowUnknownServers` | hallazgo | puntuación | fatal |
150
+ |---|---|---|---|
151
+ | `false` (fail-closed) | `SERVER_UNDECLARED`, high | 0 | sí |
152
+ | `true` | `SERVER_UNDECLARED`, info | 1 | no |
153
+
154
+ Este interruptor significaba antes «todavía no tiene puntuación de reputación», algo que ningún
155
+ despliegue podía satisfacer: nunca se suministraron aristas de confianza al oráculo, así que todo
156
+ servidor volvía sin avalar y `false` los bloqueaba todos. La procedencia de catálogo es un hecho que
157
+ el host ya tiene en local, no necesita red y no puede provocar un bloqueo mutuo.
158
+
159
+ ## pinning
160
+
161
+ Compara las definiciones actuales con la instantánea que aprobó el usuario. El hash es sha256 sobre la
162
+ forma canónica RFC 8785 del conjunto de definiciones — la misma canonicalización que usa la firma del
163
+ feed, no una segunda serialización.
164
+
165
+ | Situación | Código | Severidad | Puntuación | Fatal |
166
+ |---|---|---|---|---|
167
+ | Aún sin pin (primer contacto) | `TOOL_DEF_UNPINNED` | info | 0.9 | no |
168
+ | El hash difiere del pin | `TOOL_DEF_DRIFT` | high | 0 | con `pinToolDefs` |
169
+ | Las definiciones no tienen forma canónica (sin pin) | `TOOL_DEF_UNCANONICAL` | medium | 0.5 | no |
170
+ | Las definiciones no tienen forma canónica (con pin) | `TOOL_DEF_UNCANONICAL` | high | 0 | con `pinToolDefs` |
171
+
172
+ El primer contacto cuesta 0.1, no un bloqueo: un servidor limpio, declarado y sin pin puntúa
173
+ exactamente **0.9**, y `TOOL_DEF_UNPINNED` es `info` a propósito — con `blockAtSeverity: "info"`, un
174
+ primer encuentro bloqueante haría que todo servidor fuera inservible para siempre, ya que nada puede
175
+ fijarse antes de ser aprobado una vez.
176
+
177
+ `warden.approve(server, tools)` escribe el pin a través de tu `PinStore`. Es idempotente.
178
+
179
+ ## Partición por herramienta
180
+
181
+ `allowedTools` / `blockedTools` reparten las herramientas anunciadas:
182
+
183
+ - una herramienta está **bloqueada** si un hallazgo no advisory la nombra (`finding.tool`) y alcanza
184
+ el umbral;
185
+ - todas las demás quedan permitidas;
186
+ - las herramientas sensibles (`policy.sensitiveToolPatterns`) siguen *permitidas*: quedan marcadas
187
+ para que el bucle de tu agente pueda exigir aprobación en cada llamada en tiempo de ejecución. Ver
188
+ `classifyTools` / `isSensitiveTool`.
189
+
190
+ ## Añadir una puerta
191
+
192
+ `WardenGate` son tres líneas de interfaz, y `new Warden({ gates, policy, log })` toma la cadena
193
+ directamente, así que puedes insertar la tuya sin hacer un fork:
194
+
195
+ ```ts
196
+ import { Warden, StaticScanGate, ThreatGate, OriginGate, PinningGate } from "@aimarket/warden";
197
+ import type { WardenGate, WardenGateInput, WardenGateResult } from "@aimarket/warden";
198
+
199
+ class DenyByPublisher implements WardenGate {
200
+ readonly name = "publisher-allowlist";
201
+ async evaluate(input: WardenGateInput): Promise<WardenGateResult> {
202
+ const ok = ALLOWED.has(input.server.name);
203
+ return ok
204
+ ? { findings: [], score: 1 }
205
+ : { findings: [{ gate: this.name, severity: "high", code: "PUBLISHER_UNKNOWN",
206
+ message: `${input.server.name} no es un editor permitido` }],
207
+ score: 0, fatal: true };
208
+ }
209
+ }
210
+
211
+ const warden = new Warden({
212
+ gates: [new StaticScanGate(), new ThreatGate(feed), new DenyByPublisher(), new OriginGate(), new PinningGate(store)],
213
+ policy,
214
+ });
215
+ ```
216
+
217
+ Dos reglas para una puerta propia: **nunca afirmes que un servicio remoto es inalcanzable si no
218
+ enviaste realmente una petición** (`test/no-phantom-gate.test.ts` lo impone sobre las puertas que se
219
+ envían) y devuelve una puntuación que puedas defender — una puerta que no midió nada debe devolver
220
+ `1`, no un 0.6 «neutro», o penaliza a cada servidor por una medición que nunca hizo.
221
+
222
+ ## Las puertas en modo wrap
223
+
224
+ `vetLaunch` precede al arranque. `initialize.instructions` pasa por static-scan y se elimina al bloquearse. `tools/list` reúne como máximo 32 páginas, 256 herramientas únicas y 1 MiB antes de `vet`; solo se exponen definiciones permitidas en una página. `list_changed` activa cuarentena inmediatamente y se reenvía solo tras verificar. Cada llamada vuelve a listar y compara con la definición mostrada al cliente. Un cambio durante la comprobación impide enviar; durante la ejecución retiene el resultado. Los mensajes transparentes tienen límite de 32 MiB. Las peticiones internas caducan a los 10 segundos. EOF cierra stdin del hijo, SIGTERM llega tras 5 segundos y SIGKILL antes de 10. Las peticiones del servidor y demás mensajes conservan ID y JSON original. Consulte README para política, TOFU, revisión humana y audit-only.
@@ -0,0 +1,228 @@
1
+ # La chaîne de portes
2
+
3
+ > 🌐 [English](gates.md) · [Русский](gates.ru.md) · [Español](gates.es.md) · **Français** · [中文](gates.zh.md)
4
+
5
+ > [Sécurité et migration 0.7.0](security-hardening.fr.md).
6
+
7
+ `Warden.vet(server, tools)` exécute une chaîne ordonnée et renvoie un seul verdict. Cette page est
8
+ toute la procédure de décision : ce que regarde chaque porte, ce qu'elle peut bloquer, et comment le
9
+ nombre final se construit.
10
+
11
+ ```
12
+ static-scan → threat-feed → origin → pinning
13
+ (gratuit) (gratuit après (gratuit) (gratuit)
14
+ load)
15
+ ```
16
+
17
+ L'ordre va du moins coûteux et du plus local d'abord. Rien dans la chaîne n'effectue de requête réseau :
18
+ le seul téléchargement que WARDEN émette jamais est `ThreatFeed.load(url)`, que vous appelez vous-même,
19
+ avant tout examen.
20
+
21
+ ## Comment un verdict est assemblé
22
+
23
+ Chaque porte renvoie `{ findings, score, fatal? }`. La chaîne :
24
+
25
+ 1. exécute toutes les portes dans l'ordre, en accumulant les constats (chaque porte voit `prior`) ;
26
+ 2. multiplie les scores des portes — le composite est un **produit**, donc une porte mauvaise tire tout
27
+ le serveur vers le bas au lieu d'être moyennée par trois bonnes ;
28
+ 3. bloque si une porte a renvoyé `fatal`, ou si un constat non advisory atteint
29
+ `policy.blockAtSeverity` ;
30
+ 4. n'interrompt la chaîne **que** sur un `fatal` explicite. Un constat bloquant mais non fatal laisse
31
+ les portes restantes rapporter, pour que la trace du *pourquoi* reste complète.
32
+
33
+ ```ts
34
+ const SEVERITY_RANK = { info: 0, low: 1, medium: 2, high: 3, critical: 4 };
35
+ ```
36
+
37
+ Si `policy.blockAtSeverity` n'est pas l'une de ces cinq clés, le constructeur journalise un
38
+ avertissement et retombe sur `"high"`. Une coquille à cet endroit était auparavant la pire défaillance
39
+ possible : `rank >= undefined` est `false` pour toute comparaison, donc un seuil mal orthographié
40
+ désactivait silencieusement tout blocage.
41
+
42
+ ### Deux axes : sévérité et palier
43
+
44
+ La sévérité répond à *quelle attention cela mérite-t-il*. Le **palier** (tier) répond à *est-ce un
45
+ défaut ?* — et c'est une donnée portée par le constat (`advisory: true`), non une conséquence de la
46
+ sévérité.
47
+
48
+ Un constat `advisory` est rapporté, ne bloque jamais et ne coûte jamais un outil, avec **n'importe
49
+ quel** `blockAtSeverity`. Un outil dont le schéma accepte une `api_key` mérite qu'on le signale et n'est
50
+ pas un défaut ; l'exprimer en abaissant sa sévérité le rendait de nouveau bloquant pour quiconque
51
+ durcissait le seuil.
52
+
53
+ ## static-scan
54
+
55
+ Analyse locale par expressions régulières du `name`, de la `description` et de l'`inputSchema` de
56
+ chaque outil. 26 règles dans le jeu **v7** : 15 `block`, 11 `advise`, et 15 d'entre elles portent un **guard** de
57
+ contexte — une vérification nommée qui décide si une correspondance est vraiment ce que la règle
58
+ cherche. Voir [l'étude de terrain](mcp-survey.fr.md), le passage sur 1 108 serveurs qui a calibré v4.
59
+
60
+ **v5 : le texte est normalisé avant qu'aucune règle ne le lise.** NFKC ramène les lettres pleine chasse,
61
+ les ligatures et autres formes de compatibilité à des lettres ordinaires ; les caractères invisibles à
62
+ l'intérieur d'un mot sont retirés ; le bloc des balises Unicode (copies invisibles de l'ASCII, capables de
63
+ porter une phrase entière) est décodé ; et dans un mot qui mêle le latin au cyrillique ou au grec, les
64
+ lettres sosies deviennent latines — un mot écrit entièrement dans un seul alphabet n'est pas touché. Une
65
+ règle écrite en anglais ne se contourne donc plus par la pleine chasse, une espace sans chasse, des balises
66
+ ou un `о` cyrillique, quelle que soit la langue du texte. La normalisation est publiée comme `fold` et entre
67
+ dans l'empreinte. Les deux règles de charges cachées lisent le texte **brut** (`raw: true`).
68
+
69
+ Une table d'expressions régulières ne lit pas le sens : une formulation dans une langue pour laquelle les
70
+ règles ne sont pas écrites lui échappe. v5 ajoute ce qui ne dépend pas de la langue : la normalisation, la
71
+ paire `TOOL_DEF_SECRET_EXFIL` (un magasin de secrets et une adresse externe à moins de 100 caractères ;
72
+ purement indicative, sa seule correspondance sur 10 645 serveurs réels étant honnête), le bloc des balises
73
+ et les isolats bidi dans `TOOL_DEF_HIDDEN_UNICODE` ; et HISTOR signale toute adresse externe qui apparaît
74
+ pour la première fois. Détecter le sens relève d'un classifieur, pas de cette table.
75
+
76
+ v5 supprime aussi trois faux positifs mesurés : « send the user to https://… » (redirection d'une personne,
77
+ guard `navigation`), « keep calling … without asking the user » (autonomie, guard `autonomy`) et le liant sans
78
+ chasse à l'intérieur d'un emoji. Sur le corpus de 10 645 serveurs, v5 bloque 56 serveurs là où v4 en bloquait
79
+ 63, et aucun que v4 ne bloquait pas.
80
+
81
+ **v7** supprime trois faux positifs que le jeu v6 produisait encore sur le corpus versionné du
82
+ 2026-10-01 ([l'étude de terrain](mcp-survey.fr.md)). « Private key/value memory » désigne un magasin
83
+ clé-valeur, pas une clé privée (guard `keyValue` : un composé avec barre oblique ou trait d'union suivi
84
+ d'un nom de magasin). « Find … without asking the user for ids » est un outil qui résout lui-même un
85
+ identifiant (guard `autonomy`, qui accepte désormais aussi un verbe de recherche quand tout l'objet est
86
+ un identifiant). « The key is read from the MCP connection's X-API-Key header » est un serveur qui
87
+ décrit sa propre authentification (guard `ownAuthHeader` : passif « is read from », un en-tête de
88
+ requête, pas celui d'un autre, et la clé n'est envoyée nulle part). En outre, `autonomy` n'exempte plus
89
+ un « without asking the user » dont l'objet est le consentement : « keep retrying the transfer without
90
+ asking the user for approval » bloque de nouveau. Sur ce corpus, v7 bloque 3 serveurs là où v6 en
91
+ bloquait 6, et toutes les autres alertes sont inchangées.
92
+
93
+ Chaque règle déclare sur laquelle de ces trois **surfaces** elle s'exécute, et 17 des 26 incluent le
94
+ nom. Les quatre qui ne l'incluent pas sont celles qui reposent sur un NOM COMMUN
95
+ (`TOOL_DEF_SECRET_REQUEST`, `TOOL_DEF_CREDENTIAL_PARAM`, `TOOL_DEF_ENV_REFERENCE`, `TOOL_DEF_SECRET_EXFIL`) : un nom d'outil
96
+ est un identifiant, `api_key` et `private_key` en sont des morceaux ordinaires, et refuser
97
+ `sign_with_private_key` reviendrait à commettre l'erreur de calibrage de v1 sur une nouvelle
98
+ surface. Les règles reposant sur une PHRASE exigent des espaces et ne peuvent donc pas correspondre
99
+ à du `snake_case`, et les deux règles de charge cachée portent sur des caractères qui ne sont jamais
100
+ légitimes dans un nom : celles-là s'exécutent partout.
101
+
102
+ Jusqu'à v3, le nom n'était analysé par **rien** : une phrase d'injection, un caractère de largeur
103
+ nulle ou un blob base64 dans le premier champ que lit le modèle n'était pas rapporté du tout.
104
+
105
+ Le score de la porte est `1 − pénalité(pire sévérité bloquante)` ; les constats indicatifs ne
106
+ l'affectent jamais.
107
+
108
+ | pire sévérité bloquante | aucune | info | low | medium | high | critical |
109
+ |---|---|---|---|---|---|---|
110
+ | score de la porte | 1 | 1 | 0.9 | 0.7 | 0.4 | 0 |
111
+
112
+ | Code | Sévérité | Palier | Nom ? | Ce qu'il attrape |
113
+ |---|---|---|---|---|
114
+ | `TOOL_DEF_INJECTION` | critical / high | block | ✅ | « ignore all previous instructions », « do not tell the user », balises `<system>`, références au prompt développeur |
115
+ | `TOOL_DEF_SECRET_REQUEST` | critical | block | — | `private_key`, `seed_phrase`/`mnemonic`, chemins `~/.ssh` |
116
+ | `TOOL_DEF_SECRET_HARVEST` | critical | block | ✅ | un outil dont la mission déclarée est de lire/extraire/révéler des secrets |
117
+ | `TOOL_DEF_EXFIL` | critical / high | block | ✅ | « post to https://… », « forward it to… », « exfiltrate », formulations d'upload vers un hôte |
118
+ | `TOOL_DEF_HIDDEN_UNICODE` | high | block | ✅ | caractères de largeur nulle et de contrôle bidi — du texte que le relecteur ne voit pas |
119
+ | `TOOL_DEF_BASE64_BLOB` | high | block | ✅ | une suite base64 de 120+ caractères dans un nom, une description ou un schéma |
120
+ | `TOOL_DEF_DATA_URL` | high | block | ✅ | URLs `data:…;base64,` et `javascript:` |
121
+ | `TOOL_DEF_CREDENTIAL_PARAM` | medium / low | advise | — | schéma ou description réclamant `api_key`, `password`, `secret`, jetons bearer |
122
+ | `TOOL_DEF_ENV_REFERENCE` | medium | advise | — | `.env`, « environment variables » |
123
+ | `TOOL_DEF_IMPERATIVE` | low / info | advise | ✅ | « you must », « instead of » — formulation en forme de prompt, qui à elle seule ne prouve rien |
124
+
125
+ `staticScanRuleset()` renvoie chaque règle avec **la source de sa regex, ses drapeaux et ses surfaces**, pour qu'un
126
+ tiers puisse rejouer exactement la même règle, plus `{ version, digest }`, où l'empreinte est un sha256
127
+ sur la forme canonique RFC 8785 de la liste triée des règles. Le tri se fait par comparaison d'unités
128
+ de code, jamais `localeCompare` : une collation dépendante de la locale ferait que la même table
129
+ produise une empreinte différente sur un hôte configuré autrement — précisément la divergence que
130
+ l'empreinte existe pour détecter.
131
+
132
+ ## threat-feed
133
+
134
+ Confronte l'identité du serveur et les définitions d'outils à des `ThreatRecord` — 11 intégrés plus ce
135
+ qu'un feed signé a ajouté (voir [le contrat du feed](threat-feed.fr.md)).
136
+
137
+ - Toute correspondance ⇒ score de la porte **0**.
138
+ - `fatal` **uniquement** pour un enregistrement `critical` correspondant au *serveur*. Une
139
+ correspondance critique sur un *outil* n'est pas fatale : le reste de la chaîne continue de
140
+ rapporter et la faute reste circonscrite à cet outil — c'est ce qui permet à un serveur globalement
141
+ correct de rester utilisable avec un outil en quarantaine.
142
+ - `ThreatRecord.scope` choisit la surface : `server` (id/name/url/command/args), `tool`
143
+ (name/description/inputSchema) ou `any` — la valeur par défaut quand l'enregistrement l'omet.
144
+
145
+ Codes intégrés : `THREAT_TYPOSQUAT`, `THREAT_CRYPTO_DRAINER`, `THREAT_SEED_PHRASE`,
146
+ `THREAT_SSH_KEY_READ`, `THREAT_ENV_EXFIL`, `THREAT_DESTRUCTIVE_CMD`, `THREAT_FORK_BOMB`.
147
+
148
+ ## origin
149
+
150
+ L'opérateur a-t-il déclaré ce serveur, ou provient-il d'un catalogue distant (`McpServerRef.catalog`
151
+ est renseigné) ?
152
+
153
+ | `allowUnknownServers` | constat | score | fatal |
154
+ |---|---|---|---|
155
+ | `false` (fail-closed) | `SERVER_UNDECLARED`, high | 0 | oui |
156
+ | `true` | `SERVER_UNDECLARED`, info | 1 | non |
157
+
158
+ Ce réglage signifiait auparavant « n'a pas encore de score de réputation », ce qu'aucun déploiement ne
159
+ pouvait satisfaire : personne n'a jamais fourni d'arêtes de confiance à l'oracle, donc chaque serveur
160
+ revenait non cautionné et `false` les bloquait tous. La provenance de catalogue est un fait que l'hôte
161
+ détient déjà localement, ne demande aucun réseau et ne peut pas provoquer d'interblocage.
162
+
163
+ ## pinning
164
+
165
+ Compare les définitions actuelles à l'instantané approuvé par l'utilisateur. L'empreinte est un sha256
166
+ sur la forme canonique RFC 8785 du jeu de définitions — la même canonicalisation que la signature du
167
+ feed, pas une seconde sérialisation.
168
+
169
+ | Situation | Code | Sévérité | Score | Fatal |
170
+ |---|---|---|---|---|
171
+ | Pas encore de pin (premier contact) | `TOOL_DEF_UNPINNED` | info | 0.9 | non |
172
+ | L'empreinte diffère du pin | `TOOL_DEF_DRIFT` | high | 0 | avec `pinToolDefs` |
173
+ | Les définitions n'ont pas de forme canonique (sans pin) | `TOOL_DEF_UNCANONICAL` | medium | 0.5 | non |
174
+ | Les définitions n'ont pas de forme canonique (avec pin) | `TOOL_DEF_UNCANONICAL` | high | 0 | avec `pinToolDefs` |
175
+
176
+ Le premier contact coûte 0,1 et non un blocage : un serveur propre, déclaré et sans pin marque
177
+ exactement **0,9**, et `TOOL_DEF_UNPINNED` est en `info` à dessein — avec `blockAtSeverity: "info"`, un
178
+ premier regard bloquant rendrait tout serveur inutilisable à jamais, puisque rien ne peut être épinglé
179
+ avant d'avoir été approuvé une fois.
180
+
181
+ `warden.approve(server, tools)` écrit le pin via votre `PinStore`. L'opération est idempotente.
182
+
183
+ ## Partition par outil
184
+
185
+ `allowedTools` / `blockedTools` répartissent les outils annoncés :
186
+
187
+ - un outil est **bloqué** si un constat non advisory le nomme (`finding.tool`) et atteint le seuil ;
188
+ - tous les autres outils sont autorisés ;
189
+ - les outils sensibles (`policy.sensitiveToolPatterns`) restent *autorisés* : ils sont signalés pour que
190
+ la boucle de votre agent puisse exiger une approbation à chaque appel à l'exécution. Voir
191
+ `classifyTools` / `isSensitiveTool`.
192
+
193
+ ## Ajouter une porte
194
+
195
+ `WardenGate` fait trois lignes d'interface, et `new Warden({ gates, policy, log })` prend la chaîne
196
+ directement : vous pouvez insérer la vôtre sans forker.
197
+
198
+ ```ts
199
+ import { Warden, StaticScanGate, ThreatGate, OriginGate, PinningGate } from "@aimarket/warden";
200
+ import type { WardenGate, WardenGateInput, WardenGateResult } from "@aimarket/warden";
201
+
202
+ class DenyByPublisher implements WardenGate {
203
+ readonly name = "publisher-allowlist";
204
+ async evaluate(input: WardenGateInput): Promise<WardenGateResult> {
205
+ const ok = ALLOWED.has(input.server.name);
206
+ return ok
207
+ ? { findings: [], score: 1 }
208
+ : { findings: [{ gate: this.name, severity: "high", code: "PUBLISHER_UNKNOWN",
209
+ message: `${input.server.name} n'est pas un éditeur autorisé` }],
210
+ score: 0, fatal: true };
211
+ }
212
+ }
213
+
214
+ const warden = new Warden({
215
+ gates: [new StaticScanGate(), new ThreatGate(feed), new DenyByPublisher(), new OriginGate(), new PinningGate(store)],
216
+ policy,
217
+ });
218
+ ```
219
+
220
+ Deux règles pour une porte que vous écrivez : **ne prétendez jamais qu'un service distant est
221
+ injoignable si vous n'avez pas réellement envoyé de requête** (`test/no-phantom-gate.test.ts` l'impose
222
+ sur les portes livrées), et renvoyez un score que vous pouvez défendre — une porte qui n'a rien mesuré
223
+ doit renvoyer `1`, pas un 0,6 « neutre », sinon elle taxe chaque serveur pour une mesure qu'elle n'a
224
+ jamais prise.
225
+
226
+ ## Les portes en mode wrap
227
+
228
+ `vetLaunch` précède le lancement. `initialize.instructions` passe par static-scan et est retiré en cas de blocage. `tools/list` collecte au plus 32 pages, 256 outils uniques et 1 MiB avant `vet` ; les définitions autorisées sont exposées en une page. `list_changed` déclenche immédiatement la quarantaine et n’est transmis qu’après vérification réussie. Chaque appel relit la liste et compare avec la définition montrée au client. Une modification pendant la vérification bloque l’envoi ; pendant l’exécution, elle retient la réponse. Les messages transparents sont limités à 32 MiB. Les requêtes internes expirent après 10 secondes. EOF ferme stdin de l’enfant, SIGTERM arrive après 5 secondes puis SIGKILL avant 10. Les requêtes du serveur et les autres messages gardent leurs ID et leur JSON original. Voir README pour politique, TOFU, révision humaine et audit-only.