@aimarket/warden 0.6.0 → 0.8.0
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 +24 -1
- package/README-es.md +57 -6
- package/README-fr.md +57 -6
- package/README-ru.md +57 -6
- package/README-zh.md +57 -6
- package/README.md +57 -6
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/mcp-rpc.d.ts +3 -1
- package/dist/mcp-rpc.d.ts.map +1 -1
- package/dist/mcp-rpc.js +32 -8
- package/dist/mcp-rpc.js.map +1 -1
- package/dist/mcp-server.js +50 -44
- package/dist/mcp-server.js.map +1 -1
- package/dist/mcp-tools.d.ts +1 -12
- package/dist/mcp-tools.d.ts.map +1 -1
- package/dist/mcp-tools.js +92 -18
- package/dist/mcp-tools.js.map +1 -1
- package/dist/pin-store.d.ts +13 -0
- package/dist/pin-store.d.ts.map +1 -0
- package/dist/pin-store.js +44 -0
- package/dist/pin-store.js.map +1 -0
- package/dist/pinning.d.ts +4 -3
- package/dist/pinning.d.ts.map +1 -1
- package/dist/pinning.js +16 -3
- package/dist/pinning.js.map +1 -1
- package/dist/state.d.ts +7 -0
- package/dist/state.d.ts.map +1 -0
- package/dist/state.js +61 -0
- package/dist/state.js.map +1 -0
- package/dist/static-scan.d.ts +2 -2
- package/dist/static-scan.d.ts.map +1 -1
- package/dist/static-scan.js +107 -8
- package/dist/static-scan.js.map +1 -1
- package/dist/threat-feed.d.ts +9 -0
- package/dist/threat-feed.d.ts.map +1 -1
- package/dist/threat-feed.js +50 -7
- package/dist/threat-feed.js.map +1 -1
- package/dist/types.d.ts +9 -0
- package/dist/types.d.ts.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/docs/security-hardening.es.md +40 -0
- package/docs/security-hardening.fr.md +40 -0
- package/docs/security-hardening.md +40 -0
- package/docs/security-hardening.ru.md +40 -0
- package/docs/security-hardening.zh.md +40 -0
- package/package.json +7 -4
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.
|
package/docs/gates.md
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
# The gate chain
|
|
2
|
+
|
|
3
|
+
> 🌐 **English** · [Русский](gates.ru.md) · [Español](gates.es.md) · [Français](gates.fr.md) · [中文](gates.zh.md)
|
|
4
|
+
|
|
5
|
+
> [0.7.0 security changes and migration](security-hardening.md).
|
|
6
|
+
|
|
7
|
+
`Warden.vet(server, tools)` runs an ordered chain and returns one verdict. This page is the whole
|
|
8
|
+
decision procedure: what each gate looks at, what it may block, and how the number at the end is
|
|
9
|
+
built.
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
static-scan → threat-feed → origin → pinning
|
|
13
|
+
(free) (free after (free) (free)
|
|
14
|
+
load)
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
The order is cheapest-and-most-local first. Nothing in the chain performs a network request — the
|
|
18
|
+
only fetch WARDEN ever makes is `ThreatFeed.load(url)`, which you call yourself, before vetting.
|
|
19
|
+
|
|
20
|
+
## How a verdict is assembled
|
|
21
|
+
|
|
22
|
+
Each gate returns `{ findings, score, fatal? }`. The chain:
|
|
23
|
+
|
|
24
|
+
1. runs every gate in order, accumulating findings (each gate sees `prior`);
|
|
25
|
+
2. multiplies the gate scores — the composite is a **product**, so one bad gate drags the server down
|
|
26
|
+
instead of being averaged away by three good ones;
|
|
27
|
+
3. blocks if any gate returned `fatal`, or if any non-advisory finding reaches
|
|
28
|
+
`policy.blockAtSeverity`;
|
|
29
|
+
4. short-circuits **only** on an explicit `fatal`. A blocking-but-not-fatal finding still lets the
|
|
30
|
+
remaining gates report, so the record of *why* stays complete.
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
const SEVERITY_RANK = { info: 0, low: 1, medium: 2, high: 3, critical: 4 };
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
If `policy.blockAtSeverity` is not one of those five keys, the constructor logs a warning and falls
|
|
37
|
+
back to `"high"`. A typo there used to be the worst possible failure: `rank >= undefined` is `false`
|
|
38
|
+
for every comparison, so a misspelled threshold silently disabled blocking altogether.
|
|
39
|
+
|
|
40
|
+
### Two axes: severity and tier
|
|
41
|
+
|
|
42
|
+
Severity answers *how much attention does this deserve*. The **tier** answers *is this a defect at
|
|
43
|
+
all* — and it is data on the finding (`advisory: true`), not a consequence of severity.
|
|
44
|
+
|
|
45
|
+
An `advisory` finding is reported, never blocks, and never costs a tool, at **any**
|
|
46
|
+
`blockAtSeverity`. A tool whose schema takes an `api_key` is worth pointing at and is not a defect;
|
|
47
|
+
expressing that by lowering its severity would have made it blocking again for anyone who tightened
|
|
48
|
+
the threshold.
|
|
49
|
+
|
|
50
|
+
## static-scan
|
|
51
|
+
|
|
52
|
+
Local regex scan over each tool's `name`, its `description` and its `inputSchema`. 26 rules in
|
|
53
|
+
ruleset **v7**: 15 `block`, 11 `advise`, and 15 of them carry a context **guard** — a named
|
|
54
|
+
check that decides whether a match is really the thing the rule is looking for. See
|
|
55
|
+
[the field survey](mcp-survey.md) for the 1 108-server run that calibrated v4.
|
|
56
|
+
|
|
57
|
+
**v5: the text is folded before any rule reads it.** NFKC maps fullwidth letters, ligatures and
|
|
58
|
+
other compatibility forms to plain ones; invisible characters inside a word are dropped; the Unicode
|
|
59
|
+
TAG block (invisible copies of ASCII that can carry a whole sentence) is decoded to the ASCII it
|
|
60
|
+
hides; and inside a word that mixes Latin with Cyrillic or Greek, look-alike letters are mapped to
|
|
61
|
+
Latin — a word written wholly in one script is left alone. A rule written in English therefore cannot
|
|
62
|
+
be dodged by `ignore`, a zero-width space inside the word, tag characters, or a Cyrillic `о`, in any
|
|
63
|
+
language the surrounding text is written in. The fold is published as `fold` beside the rules and is
|
|
64
|
+
part of the digest. The two hidden-payload rules read the **raw** text (`raw: true`), because they
|
|
65
|
+
look for exactly what the fold removes.
|
|
66
|
+
|
|
67
|
+
What a regex table cannot do is read meaning: a phrasing in a language the rules were not written in
|
|
68
|
+
is outside it. v5 adds what does not depend on the language — the fold above, the
|
|
69
|
+
`TOOL_DEF_SECRET_EXFIL` pair (a secret store and an external address within 100 characters of each
|
|
70
|
+
other, advisory because its only hit on 10 645 live servers was honest), the Unicode-tag block and
|
|
71
|
+
bidi isolates in `TOOL_DEF_HIDDEN_UNICODE` — and HISTOR reports any outside address that newly
|
|
72
|
+
appears in a server's definitions. Meaning-based detection belongs to a classifier, not to this table.
|
|
73
|
+
|
|
74
|
+
v5 also removes three measured false positives: "send the user to https://…" (a redirect of a
|
|
75
|
+
person, guard `navigation`), "keep calling … without asking the user" (autonomy, guard `autonomy`),
|
|
76
|
+
and a zero-width joiner inside an emoji sequence. On the 10 645-server corpus v5 blocks 56 servers
|
|
77
|
+
where v4 blocked 63, and blocks none that v4 did not.
|
|
78
|
+
|
|
79
|
+
**v7** removes three false positives that ruleset v6 still had on the committed 2026-10-01 corpus
|
|
80
|
+
([the field survey](mcp-survey.md)). "Private key/value memory" names a key-value store, not a
|
|
81
|
+
private key (guard `keyValue`: a slash or hyphen compound followed by a store noun). "Find … without
|
|
82
|
+
asking the user for ids" is a tool resolving an identifier itself (guard `autonomy`, which now also
|
|
83
|
+
accepts a lookup verb with an identifier as the whole object). "The key is read from the MCP
|
|
84
|
+
connection's X-API-Key header" is a server describing its own authentication (guard
|
|
85
|
+
`ownAuthHeader`: passive "is read from", a request header, nobody else's, nothing moved onward).
|
|
86
|
+
`autonomy` also no longer exempts a "without asking the user" whose object is consent: "keep
|
|
87
|
+
retrying the transfer without asking the user for approval" blocks again. On that corpus v7 blocks 3
|
|
88
|
+
servers where v6 blocked 6, and every other finding is unchanged.
|
|
89
|
+
|
|
90
|
+
Every rule declares which of those three **surfaces** it runs on, and 17 of the 26 include the name.
|
|
91
|
+
The four that do not are the noun-keyed ones (`TOOL_DEF_SECRET_REQUEST`,
|
|
92
|
+
`TOOL_DEF_CREDENTIAL_PARAM`, `TOOL_DEF_ENV_REFERENCE`, `TOOL_DEF_SECRET_EXFIL`): a name is an identifier, `api_key` and
|
|
93
|
+
`private_key` are ordinary parts of one, and refusing `sign_with_private_key` would be the ruleset
|
|
94
|
+
v1 calibration error committed on a new surface. Phrase-keyed rules need whitespace and so cannot
|
|
95
|
+
match `snake_case` at all, and the two hidden-payload rules are about characters that are never
|
|
96
|
+
legitimate in a name — those run everywhere.
|
|
97
|
+
|
|
98
|
+
Until v3 the name was scanned by **nothing**, which meant an injection phrase, a zero-width
|
|
99
|
+
character or a base64 blob in the first field the model reads went entirely unreported.
|
|
100
|
+
|
|
101
|
+
Gate score is `1 − penalty(worst blocking severity)`; advisory hits never affect it.
|
|
102
|
+
|
|
103
|
+
| worst blocking severity | none | info | low | medium | high | critical |
|
|
104
|
+
|---|---|---|---|---|---|---|
|
|
105
|
+
| gate score | 1 | 1 | 0.9 | 0.7 | 0.4 | 0 |
|
|
106
|
+
|
|
107
|
+
| Code | Severity | Tier | Name? | What it catches |
|
|
108
|
+
|---|---|---|---|---|
|
|
109
|
+
| `TOOL_DEF_INJECTION` | critical / high | block | ✅ | "ignore all previous instructions", "do not tell the user", `<system>` tags, references to the developer prompt |
|
|
110
|
+
| `TOOL_DEF_SECRET_REQUEST` | critical | block | — | `private_key`, `seed_phrase`/`mnemonic`, `~/.ssh` paths |
|
|
111
|
+
| `TOOL_DEF_SECRET_HARVEST` | critical | block | ✅ | a tool whose stated job is to read/dump/reveal secrets |
|
|
112
|
+
| `TOOL_DEF_EXFIL` | critical / high | block | ✅ | "post to https://…", "forward it to…", "exfiltrate", upload-to-host phrasing |
|
|
113
|
+
| `TOOL_DEF_HIDDEN_UNICODE` | high | block | ✅ | zero-width, bidi control and isolate characters, and the Unicode-tag block — text the reviewer cannot see |
|
|
114
|
+
| `TOOL_DEF_BASE64_BLOB` | high | block | ✅ | a 120+ character base64 run in a name, description or schema |
|
|
115
|
+
| `TOOL_DEF_DATA_URL` | high | block | ✅ | `data:…;base64,` and `javascript:` URLs |
|
|
116
|
+
| `TOOL_DEF_CREDENTIAL_PARAM` | medium / low | advise | — | schema or description asking for `api_key`, `password`, `secret`, bearer tokens |
|
|
117
|
+
| `TOOL_DEF_ENV_REFERENCE` | medium | advise | — | `.env`, "environment variables" |
|
|
118
|
+
| `TOOL_DEF_SECRET_EXFIL` | medium | advise | — | a secret store (`.env`, `~/.ssh/…`, `~/.aws/credentials`) and a URL, e-mail or host within 100 characters — the language-independent shape of "read this, send it there" |
|
|
119
|
+
| `TOOL_DEF_IMPERATIVE` | low / info | advise | ✅ | "you must", "instead of" — prompt-shaped phrasing, not proof of anything |
|
|
120
|
+
|
|
121
|
+
`staticScanRuleset()` returns every rule with its **regex source, flags, surfaces, guards and `raw` flag**, and
|
|
122
|
+
the `fold` identity, so a third party can re-run the exact rule, plus `{ version, digest }` where the digest is sha256 over the RFC 8785
|
|
123
|
+
canonical form of the sorted rule list. Sorting is by code-unit comparison, never `localeCompare`: a
|
|
124
|
+
locale-dependent collation would make the same table digest differently on a differently-configured
|
|
125
|
+
host, which is exactly the divergence the digest exists to detect.
|
|
126
|
+
|
|
127
|
+
## threat-feed
|
|
128
|
+
|
|
129
|
+
Matches server identity and tool definitions against `ThreatRecord`s — 11 built-in plus whatever a
|
|
130
|
+
signed feed added (see [the feed contract](threat-feed.md)).
|
|
131
|
+
|
|
132
|
+
- Any match ⇒ gate score **0**.
|
|
133
|
+
- `fatal` **only** for a `critical` record matched against the *server*. A critical match on one
|
|
134
|
+
*tool* is not fatal, so the rest of the chain still reports and the blame stays scoped to that tool
|
|
135
|
+
— which is what lets a mostly-fine server keep working with one tool quarantined.
|
|
136
|
+
- `ThreatRecord.scope` selects the surface: `server` (id/name/url/command/args), `tool`
|
|
137
|
+
(name/description/inputSchema), or `any` — the default when a record omits it.
|
|
138
|
+
|
|
139
|
+
Built-in codes: `THREAT_TYPOSQUAT`, `THREAT_CRYPTO_DRAINER`, `THREAT_SEED_PHRASE`,
|
|
140
|
+
`THREAT_SSH_KEY_READ`, `THREAT_ENV_EXFIL`, `THREAT_DESTRUCTIVE_CMD`, `THREAT_FORK_BOMB`.
|
|
141
|
+
|
|
142
|
+
## origin
|
|
143
|
+
|
|
144
|
+
Did the operator declare this server, or did it arrive from a remote catalog (`McpServerRef.catalog`
|
|
145
|
+
is set)?
|
|
146
|
+
|
|
147
|
+
| `allowUnknownServers` | finding | score | fatal |
|
|
148
|
+
|---|---|---|---|
|
|
149
|
+
| `false` (fail-closed) | `SERVER_UNDECLARED`, high | 0 | yes |
|
|
150
|
+
| `true` | `SERVER_UNDECLARED`, info | 1 | no |
|
|
151
|
+
|
|
152
|
+
This knob used to mean "has no reputation score yet", which no deployment could satisfy — nothing
|
|
153
|
+
ever supplied trust edges to the oracle, so every server came back unvouched and `false` blocked all
|
|
154
|
+
of them. Catalog provenance is a fact the host already holds locally, needs no network, and cannot
|
|
155
|
+
deadlock.
|
|
156
|
+
|
|
157
|
+
## pinning
|
|
158
|
+
|
|
159
|
+
Compares the current tool defs against the snapshot the user approved. The hash is sha256 over the
|
|
160
|
+
RFC 8785 canonical form of the tool-def set — the same canonicalization the feed signature uses, not
|
|
161
|
+
a second serialization.
|
|
162
|
+
|
|
163
|
+
| Situation | Code | Severity | Score | Fatal |
|
|
164
|
+
|---|---|---|---|---|
|
|
165
|
+
| No pin yet (first contact) | `TOOL_DEF_UNPINNED` | info | 0.9 | no |
|
|
166
|
+
| Hash differs from the pin | `TOOL_DEF_DRIFT` | high | 0 | under `pinToolDefs` |
|
|
167
|
+
| Tool defs have no canonical form (unpinned) | `TOOL_DEF_UNCANONICAL` | medium | 0.5 | no |
|
|
168
|
+
| Tool defs have no canonical form (pinned) | `TOOL_DEF_UNCANONICAL` | high | 0 | under `pinToolDefs` |
|
|
169
|
+
|
|
170
|
+
First contact costs 0.1, not a block: a clean, declared, unpinned server scores exactly **0.9**, and
|
|
171
|
+
`TOOL_DEF_UNPINNED` is `info` on purpose — at `blockAtSeverity: "info"` a blocking first sight would
|
|
172
|
+
make every server unusable forever, since nothing can be pinned before it is approved once.
|
|
173
|
+
|
|
174
|
+
`warden.approve(server, tools)` writes the pin through your `PinStore`. It is idempotent.
|
|
175
|
+
|
|
176
|
+
## Per-tool partition
|
|
177
|
+
|
|
178
|
+
`allowedTools` / `blockedTools` split the advertised tools:
|
|
179
|
+
|
|
180
|
+
- a tool is **blocked** if a non-advisory finding names it (`finding.tool`) and reaches the threshold;
|
|
181
|
+
- every other tool is allowed;
|
|
182
|
+
- sensitive tools (`policy.sensitiveToolPatterns`) stay *allowed* — they are flagged so your agent
|
|
183
|
+
loop can demand per-call approval at run time. See `classifyTools` / `isSensitiveTool`.
|
|
184
|
+
|
|
185
|
+
## Adding a gate
|
|
186
|
+
|
|
187
|
+
`WardenGate` is three lines of interface, and `new Warden({ gates, policy, log })` takes the chain
|
|
188
|
+
directly, so you can insert your own without forking:
|
|
189
|
+
|
|
190
|
+
```ts
|
|
191
|
+
import { Warden, StaticScanGate, ThreatGate, OriginGate, PinningGate } from "@aimarket/warden";
|
|
192
|
+
import type { WardenGate, WardenGateInput, WardenGateResult } from "@aimarket/warden";
|
|
193
|
+
|
|
194
|
+
class DenyByPublisher implements WardenGate {
|
|
195
|
+
readonly name = "publisher-allowlist";
|
|
196
|
+
async evaluate(input: WardenGateInput): Promise<WardenGateResult> {
|
|
197
|
+
const ok = ALLOWED.has(input.server.name);
|
|
198
|
+
return ok
|
|
199
|
+
? { findings: [], score: 1 }
|
|
200
|
+
: { findings: [{ gate: this.name, severity: "high", code: "PUBLISHER_UNKNOWN",
|
|
201
|
+
message: `${input.server.name} is not an allowed publisher` }],
|
|
202
|
+
score: 0, fatal: true };
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const warden = new Warden({
|
|
207
|
+
gates: [new StaticScanGate(), new ThreatGate(feed), new DenyByPublisher(), new OriginGate(), new PinningGate(store)],
|
|
208
|
+
policy,
|
|
209
|
+
});
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Two rules for a gate you write: **never claim a remote service is unreachable unless you actually
|
|
213
|
+
sent a request** (`test/no-phantom-gate.test.ts` enforces this over the shipped gates), and return a
|
|
214
|
+
score you can defend — a gate that measured nothing must return `1`, not a "neutral" 0.6, or it taxes
|
|
215
|
+
every server for a measurement it never took.
|
|
216
|
+
|
|
217
|
+
## Wrap and the gates
|
|
218
|
+
|
|
219
|
+
`vetLaunch` runs before spawn. `initialize.instructions` is scanned with the existing static gate and stripped on a blocking verdict. `tools/list` collects at most 32 pages, 256 unique tools and 1 MiB before `vet`; only allowed definitions are exposed in one page. `list_changed` immediately quarantines the server; notifications are forwarded only after a successful refresh. Every call re-lists and compares the definition with the last list shown to the client. A change during verification rejects dispatch; during execution it withholds the response. Transparent frames have a separate 32 MiB limit. Internal page requests time out after 10 seconds. EOF closes child stdin, sends SIGTERM after 5 seconds and SIGKILL just before 10 seconds. Server-to-client requests and unrelated message bodies retain their IDs and raw JSON. See the README for policy, TOFU, human review and audit-only limitations.
|