@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.
- package/CHANGELOG.md +14 -1
- package/README-es.md +52 -6
- package/README-fr.md +52 -6
- package/README-ru.md +51 -5
- package/README-zh.md +52 -6
- package/README.md +52 -6
- package/dist/mcp-rpc.js +1 -1
- package/dist/mcp-server.js +24 -1
- package/dist/mcp-server.js.map +1 -1
- package/dist/mcp-tools.d.ts +1 -0
- package/dist/mcp-tools.d.ts.map +1 -1
- package/dist/mcp-tools.js +3 -3
- package/dist/mcp-tools.js.map +1 -1
- package/dist/static-scan.d.ts +1 -1
- package/dist/static-scan.d.ts.map +1 -1
- package/dist/static-scan.js +93 -4
- package/dist/static-scan.js.map +1 -1
- package/dist/wrap-cli.d.ts +21 -0
- package/dist/wrap-cli.d.ts.map +1 -0
- package/dist/wrap-cli.js +158 -0
- package/dist/wrap-cli.js.map +1 -0
- package/dist/wrap-state.d.ts +19 -0
- package/dist/wrap-state.d.ts.map +1 -0
- package/dist/wrap-state.js +62 -0
- package/dist/wrap-state.js.map +1 -0
- package/dist/wrap-wire.d.ts +13 -0
- package/dist/wrap-wire.d.ts.map +1 -0
- package/dist/wrap-wire.js +88 -0
- package/dist/wrap-wire.js.map +1 -0
- package/dist/wrap.d.ts +3 -0
- package/dist/wrap.d.ts.map +1 -0
- package/dist/wrap.js +316 -0
- package/dist/wrap.js.map +1 -0
- package/docs/gates.es.md +224 -0
- package/docs/gates.fr.md +228 -0
- package/docs/gates.md +219 -0
- package/docs/gates.ru.md +225 -0
- package/docs/gates.zh.md +178 -0
- package/package.json +6 -4
package/docs/gates.es.md
ADDED
|
@@ -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.
|
package/docs/gates.fr.md
ADDED
|
@@ -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.
|