@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.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.
|
package/docs/gates.ru.md
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# Цепочка гейтов
|
|
2
|
+
|
|
3
|
+
> 🌐 [English](gates.md) · **Русский** · [Español](gates.es.md) · [Français](gates.fr.md) · [中文](gates.zh.md)
|
|
4
|
+
|
|
5
|
+
> [Изменения безопасности 0.7.0 и миграция](security-hardening.ru.md).
|
|
6
|
+
|
|
7
|
+
`Warden.vet(server, tools)` прогоняет упорядоченную цепочку и возвращает один вердикт. Эта страница —
|
|
8
|
+
вся процедура принятия решения: на что смотрит каждый гейт, что он может заблокировать и как
|
|
9
|
+
складывается итоговое число.
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
static-scan → threat-feed → origin → pinning
|
|
13
|
+
(бесплатно) (бесплатно (бесплатно) (бесплатно)
|
|
14
|
+
после load)
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Порядок — от самого дешёвого и самого локального. Ни один гейт в цепочке не делает сетевых запросов:
|
|
18
|
+
единственная загрузка, которую WARDEN вообще выполняет, — это `ThreatFeed.load(url)`, и её вызываете
|
|
19
|
+
вы сами, до проверки.
|
|
20
|
+
|
|
21
|
+
## Как собирается вердикт
|
|
22
|
+
|
|
23
|
+
Каждый гейт возвращает `{ findings, score, fatal? }`. Цепочка:
|
|
24
|
+
|
|
25
|
+
1. прогоняет все гейты по порядку, накапливая находки (каждый гейт видит `prior`);
|
|
26
|
+
2. перемножает оценки гейтов — composite score это **произведение**, поэтому один плохой гейт тянет
|
|
27
|
+
сервер вниз, а не усредняется тремя хорошими;
|
|
28
|
+
3. блокирует, если какой-то гейт вернул `fatal` или если какая-то не-advisory находка достигает
|
|
29
|
+
`policy.blockAtSeverity`;
|
|
30
|
+
4. обрывает цепочку **только** на явном `fatal`. Блокирующая, но не фатальная находка позволяет
|
|
31
|
+
остальным гейтам отчитаться, чтобы запись о том, *почему*, осталась полной.
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
const SEVERITY_RANK = { info: 0, low: 1, medium: 2, high: 3, critical: 4 };
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Если `policy.blockAtSeverity` не один из этих пяти ключей, конструктор пишет предупреждение и
|
|
38
|
+
откатывается на `"high"`. Опечатка здесь раньше была худшим из возможных отказов: `rank >= undefined`
|
|
39
|
+
ложно при любом сравнении, поэтому неправильно написанный порог молча отключал блокировку целиком.
|
|
40
|
+
|
|
41
|
+
### Две оси: severity и tier
|
|
42
|
+
|
|
43
|
+
Severity отвечает на вопрос *сколько внимания это заслуживает*. **Tier** отвечает на вопрос *дефект
|
|
44
|
+
ли это вообще* — и он является данными на находке (`advisory: true`), а не следствием severity.
|
|
45
|
+
|
|
46
|
+
Находка с `advisory` сообщается, никогда не блокирует и никогда не стоит инструмента — при **любом**
|
|
47
|
+
`blockAtSeverity`. Инструмент, чья схема принимает `api_key`, стоит того, чтобы на него указали, и не
|
|
48
|
+
является дефектом; выражение этого через понижение severity снова делало бы его блокирующим для всех,
|
|
49
|
+
кто ужесточил порог.
|
|
50
|
+
|
|
51
|
+
## static-scan
|
|
52
|
+
|
|
53
|
+
Локальное regex-сканирование `name`, `description` и `inputSchema` каждого инструмента. 26 правил в
|
|
54
|
+
наборе **v7**: 15 `block`, 11 `advise`, и у 15 из них есть контекстный **guard** — именованная
|
|
55
|
+
проверка, решающая, действительно ли совпадение — то, что правило ищет. См.
|
|
56
|
+
[полевой обзор](mcp-survey.ru.md) — прогон по 1 108 серверам, на котором калибровался v4.
|
|
57
|
+
|
|
58
|
+
**v5: текст приводится к обычному виду до того, как его читает любое правило.** NFKC превращает
|
|
59
|
+
широкие буквы, лигатуры и другие совместимые формы в обычные; невидимые символы внутри слова
|
|
60
|
+
выбрасываются; блок Unicode-тегов (невидимые копии ASCII, в которых можно спрятать целое предложение)
|
|
61
|
+
расшифровывается; внутри слова, где латиница смешана с кириллицей или греческим, буквы-двойники
|
|
62
|
+
заменяются латинскими, а слово целиком на одном алфавите не трогается. Английское правило больше не
|
|
63
|
+
обходится широкими буквами, пробелом нулевой ширины, тегами или кириллической `о` — на каком бы языке
|
|
64
|
+
ни был окружающий текст. Идентификатор свёртки публикуется как `fold` и входит в дайджест. Два правила
|
|
65
|
+
скрытых нагрузок читают **сырой** текст (`raw: true`): они ищут именно то, что свёртка убирает.
|
|
66
|
+
|
|
67
|
+
Смысл регэксп-таблица не читает: формулировка на языке, для которого правила не написаны, вне её.
|
|
68
|
+
v5 добавляет то, что от языка не зависит: свёртку, пару `TOOL_DEF_SECRET_EXFIL` (хранилище секретов и
|
|
69
|
+
внешний адрес в пределах 100 символов; только уведомляет — её единственное срабатывание на 10 645
|
|
70
|
+
живых серверах оказалось честным), блок тегов и изоляты направления в `TOOL_DEF_HIDDEN_UNICODE`, а
|
|
71
|
+
HISTOR показывает каждый внешний адрес, впервые появившийся в определениях сервера. Распознавание по
|
|
72
|
+
смыслу — задача классификатора, а не этой таблицы.
|
|
73
|
+
|
|
74
|
+
Ещё v5 убирает три измеренных ложных срабатывания: «send the user to https://…» (переход человека,
|
|
75
|
+
guard `navigation`), «keep calling … without asking the user» (самостоятельная работа, guard
|
|
76
|
+
`autonomy`) и соединитель нулевой ширины внутри эмодзи. На корпусе из 10 645 серверов v5 блокирует 56
|
|
77
|
+
там, где v4 блокировал 63, и не блокирует ни одного, которого не блокировал v4.
|
|
78
|
+
|
|
79
|
+
**v7** убирает три ложных срабатывания, которые ruleset v6 ещё давал на закоммиченном корпусе
|
|
80
|
+
2026-10-01 ([полевое обследование](mcp-survey.ru.md)). «Private key/value memory» — хранилище
|
|
81
|
+
ключ-значение, а не закрытый ключ (guard `keyValue`: составное слово через косую черту или дефис и
|
|
82
|
+
следом слово-хранилище). «Find … without asking the user for ids» — инструмент сам находит
|
|
83
|
+
идентификатор (guard `autonomy` теперь принимает и глагол поиска, если весь объект просьбы —
|
|
84
|
+
идентификатор). «The key is read from the MCP connection's X-API-Key header» — сервер описывает
|
|
85
|
+
собственную аутентификацию (guard `ownAuthHeader`: пассив «is read from», заголовок запроса, не чужой,
|
|
86
|
+
ключ никуда не передаётся дальше). Кроме того, `autonomy` больше не освобождает «without asking the
|
|
87
|
+
user», если объект — согласие: «keep retrying the transfer without asking the user for approval» снова
|
|
88
|
+
блокируется. На этом корпусе v7 блокирует 3 сервера там, где v6 блокировал 6, остальные находки не
|
|
89
|
+
меняются.
|
|
90
|
+
|
|
91
|
+
Каждое правило объявляет, на каких из этих трёх **поверхностей** оно работает; 17 из 26 включают
|
|
92
|
+
имя. Четыре, которые не включают, — это правила, завязанные на существительное
|
|
93
|
+
(`TOOL_DEF_SECRET_REQUEST`, `TOOL_DEF_CREDENTIAL_PARAM`, `TOOL_DEF_ENV_REFERENCE`, `TOOL_DEF_SECRET_EXFIL`): имя — это
|
|
94
|
+
идентификатор, `api_key` и `private_key` — его обычные части, и блокировать
|
|
95
|
+
`sign_with_private_key` означало бы повторить ошибку калибровки v1 на новой поверхности. Правила,
|
|
96
|
+
завязанные на фразу, требуют пробелов и потому не могут совпасть с `snake_case` вовсе, а два
|
|
97
|
+
правила о скрытой нагрузке — про символы, которые в имени не легитимны никогда: эти работают везде.
|
|
98
|
+
|
|
99
|
+
До v3 имя не сканировалось **ничем** — то есть инъекционная фраза, zero-width символ или
|
|
100
|
+
base64-блоб в первом поле, которое читает модель, не попадали в отчёт совсем.
|
|
101
|
+
|
|
102
|
+
Оценка гейта — `1 − штраф(худшая блокирующая severity)`; advisory-срабатывания на неё не влияют.
|
|
103
|
+
|
|
104
|
+
| худшая блокирующая severity | нет | info | low | medium | high | critical |
|
|
105
|
+
|---|---|---|---|---|---|---|
|
|
106
|
+
| оценка гейта | 1 | 1 | 0.9 | 0.7 | 0.4 | 0 |
|
|
107
|
+
|
|
108
|
+
| Код | Severity | Tier | Имя? | Что ловит |
|
|
109
|
+
|---|---|---|---|---|
|
|
110
|
+
| `TOOL_DEF_INJECTION` | critical / high | block | ✅ | «ignore all previous instructions», «do not tell the user», теги `<system>`, ссылки на developer prompt |
|
|
111
|
+
| `TOOL_DEF_SECRET_REQUEST` | critical | block | — | `private_key`, `seed_phrase`/`mnemonic`, пути `~/.ssh` |
|
|
112
|
+
| `TOOL_DEF_SECRET_HARVEST` | critical | block | ✅ | инструмент, чья заявленная задача — читать/выгружать/раскрывать секреты |
|
|
113
|
+
| `TOOL_DEF_EXFIL` | critical / high | block | ✅ | «post to https://…», «forward it to…», «exfiltrate», формулировки про upload на хост |
|
|
114
|
+
| `TOOL_DEF_HIDDEN_UNICODE` | high | block | ✅ | zero-width и bidi-управляющие символы — текст, которого рецензент не видит |
|
|
115
|
+
| `TOOL_DEF_BASE64_BLOB` | high | block | ✅ | цепочка base64 от 120 символов в имени, описании или схеме |
|
|
116
|
+
| `TOOL_DEF_DATA_URL` | high | block | ✅ | URL вида `data:…;base64,` и `javascript:` |
|
|
117
|
+
| `TOOL_DEF_CREDENTIAL_PARAM` | medium / low | advise | — | схема или описание просит `api_key`, `password`, `secret`, bearer-токены |
|
|
118
|
+
| `TOOL_DEF_ENV_REFERENCE` | medium | advise | — | `.env`, «environment variables» |
|
|
119
|
+
| `TOOL_DEF_IMPERATIVE` | low / info | advise | ✅ | «you must», «instead of» — формулировки в форме промпта, сами по себе ничего не доказывающие |
|
|
120
|
+
|
|
121
|
+
`staticScanRuleset()` возвращает каждое правило вместе с **исходником регулярки, флагами и поверхностями**, чтобы
|
|
122
|
+
третья сторона могла перезапустить ровно то же правило, плюс `{ version, digest }`, где digest —
|
|
123
|
+
sha256 по канонической форме RFC 8785 отсортированного списка правил. Сортировка — сравнением
|
|
124
|
+
code-unit, никогда не `localeCompare`: зависящая от локали коллация давала бы для одной и той же
|
|
125
|
+
таблицы разный digest на по-разному настроенном хосте, а это ровно то расхождение, для обнаружения
|
|
126
|
+
которого digest и существует.
|
|
127
|
+
|
|
128
|
+
## threat-feed
|
|
129
|
+
|
|
130
|
+
Сопоставляет идентичность сервера и определения инструментов с записями `ThreatRecord` — 11
|
|
131
|
+
встроенных плюс то, что добавил подписанный feed (см. [контракт feed](threat-feed.ru.md)).
|
|
132
|
+
|
|
133
|
+
- Любое совпадение ⇒ оценка гейта **0**.
|
|
134
|
+
- `fatal` **только** для записи `critical`, совпавшей с *сервером*. Критическое совпадение на одном
|
|
135
|
+
*инструменте* не фатально: остальная цепочка всё равно отчитывается, а вина остаётся привязанной к
|
|
136
|
+
этому инструменту — именно это позволяет в основном нормальному серверу продолжать работать с одним
|
|
137
|
+
изолированным инструментом.
|
|
138
|
+
- `ThreatRecord.scope` выбирает поверхность: `server` (id/name/url/command/args), `tool`
|
|
139
|
+
(name/description/inputSchema) или `any` — значение по умолчанию, когда запись его не указывает.
|
|
140
|
+
|
|
141
|
+
Встроенные коды: `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
|
+
Объявил ли оператор этот сервер, или он пришёл из удалённого каталога (заполнено
|
|
147
|
+
`McpServerRef.catalog`)?
|
|
148
|
+
|
|
149
|
+
| `allowUnknownServers` | находка | оценка | fatal |
|
|
150
|
+
|---|---|---|---|
|
|
151
|
+
| `false` (fail-closed) | `SERVER_UNDECLARED`, high | 0 | да |
|
|
152
|
+
| `true` | `SERVER_UNDECLARED`, info | 1 | нет |
|
|
153
|
+
|
|
154
|
+
Раньше этот переключатель означал «ещё нет оценки репутации», чего не могло выполнить ни одно
|
|
155
|
+
развёртывание: оракулу никто никогда не передавал trust-рёбра, поэтому каждый сервер возвращался
|
|
156
|
+
непоручённым, и `false` блокировал их все. Происхождение из каталога — факт, которым хост уже
|
|
157
|
+
располагает локально, он не требует сети и не может привести к дедлоку.
|
|
158
|
+
|
|
159
|
+
## pinning
|
|
160
|
+
|
|
161
|
+
Сравнивает текущие определения инструментов со снимком, который подтвердил пользователь. Хеш — sha256
|
|
162
|
+
по канонической форме RFC 8785 набора определений; та же канонизация, что у подписи feed, а не вторая
|
|
163
|
+
сериализация.
|
|
164
|
+
|
|
165
|
+
| Ситуация | Код | Severity | Оценка | Fatal |
|
|
166
|
+
|---|---|---|---|---|
|
|
167
|
+
| Pin ещё нет (первый контакт) | `TOOL_DEF_UNPINNED` | info | 0.9 | нет |
|
|
168
|
+
| Хеш отличается от pin | `TOOL_DEF_DRIFT` | high | 0 | при `pinToolDefs` |
|
|
169
|
+
| У определений нет канонической формы (без pin) | `TOOL_DEF_UNCANONICAL` | medium | 0.5 | нет |
|
|
170
|
+
| У определений нет канонической формы (с pin) | `TOOL_DEF_UNCANONICAL` | high | 0 | при `pinToolDefs` |
|
|
171
|
+
|
|
172
|
+
Первый контакт стоит 0.1, а не блокировки: чистый объявленный сервер без pin получает ровно **0.9**, а
|
|
173
|
+
`TOOL_DEF_UNPINNED` имеет severity `info` намеренно — при `blockAtSeverity: "info"` блокирующий первый
|
|
174
|
+
взгляд сделал бы любой сервер навсегда непригодным, ведь ничего нельзя зафиксировать до того, как это
|
|
175
|
+
хоть раз подтвердили.
|
|
176
|
+
|
|
177
|
+
`warden.approve(server, tools)` записывает pin через ваш `PinStore`. Операция идемпотентна.
|
|
178
|
+
|
|
179
|
+
## Разбиение по инструментам
|
|
180
|
+
|
|
181
|
+
`allowedTools` / `blockedTools` делят объявленные инструменты:
|
|
182
|
+
|
|
183
|
+
- инструмент **заблокирован**, если не-advisory находка называет его (`finding.tool`) и достигает
|
|
184
|
+
порога;
|
|
185
|
+
- все остальные инструменты разрешены;
|
|
186
|
+
- чувствительные инструменты (`policy.sensitiveToolPatterns`) остаются *разрешёнными* — они помечены,
|
|
187
|
+
чтобы цикл вашего агента мог требовать подтверждение на каждый вызов во время работы. См.
|
|
188
|
+
`classifyTools` / `isSensitiveTool`.
|
|
189
|
+
|
|
190
|
+
## Как добавить свой гейт
|
|
191
|
+
|
|
192
|
+
`WardenGate` — интерфейс из трёх строк, а `new Warden({ gates, policy, log })` принимает цепочку
|
|
193
|
+
напрямую, поэтому свой гейт можно вставить без форка:
|
|
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} не входит в список разрешённых издателей` }],
|
|
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
|
+
Два правила для гейта, который вы пишете сами: **никогда не заявляйте, что удалённый сервис
|
|
218
|
+
недоступен, если вы действительно не отправили запрос** (`test/no-phantom-gate.test.ts` следит за этим
|
|
219
|
+
на поставляемых гейтах) и возвращайте оценку, которую можете защитить — гейт, который ничего не
|
|
220
|
+
измерил, должен вернуть `1`, а не «нейтральные» 0.6, иначе он штрафует каждый сервер за измерение,
|
|
221
|
+
которого не проводил.
|
|
222
|
+
|
|
223
|
+
## Гейты в режиме wrap
|
|
224
|
+
|
|
225
|
+
`vetLaunch` вызывается до запуска процесса. `initialize.instructions` проходит существующий static-scan и удаляется при блокирующей находке. `tools/list` собирает не более 32 страниц, 256 уникальных инструментов и 1 MiB, затем вызывает `vet`; клиент получает проверенные определения одной страницей. `list_changed` немедленно включает карантин; уведомление уходит клиенту только после успешной проверки. Перед каждым вызовом список перечитывается, а определение сравнивается с последним показанным клиенту. Изменение во время проверки запрещает отправку; во время выполнения скрывает ответ. Сквозные сообщения ограничены отдельно — 32 MiB. Таймаут внутреннего запроса списка — 10 секунд. При EOF закрывается stdin дочернего, через 5 секунд отправляется SIGTERM, перед истечением 10 секунд — SIGKILL. Встречные запросы и прочие сообщения сохраняют ID и исходный JSON. Политика, TOFU, ручное одобрение и ограничения audit-only описаны в README.
|
package/docs/gates.zh.md
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# 门控链
|
|
2
|
+
|
|
3
|
+
> 🌐 [English](gates.md) · [Русский](gates.ru.md) · [Español](gates.es.md) · [Français](gates.fr.md) · **中文**
|
|
4
|
+
|
|
5
|
+
> [0.7.0 安全更新与迁移](security-hardening.zh.md).
|
|
6
|
+
|
|
7
|
+
`Warden.vet(server, tools)` 会按顺序跑完一条门控链,并返回单一裁定。本页就是完整的判定过程:每个门控看什么、
|
|
8
|
+
它可以阻止什么,以及最后那个数字是怎么算出来的。
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
static-scan → threat-feed → origin → pinning
|
|
12
|
+
(免费) (load 之后 (免费) (免费)
|
|
13
|
+
免费)
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
顺序是「最便宜、最本地的先来」。链上没有任何环节发起网络请求——WARDEN 唯一会发出的下载是
|
|
17
|
+
`ThreatFeed.load(url)`,而那是你自己在审查之前调用的。
|
|
18
|
+
|
|
19
|
+
## 裁定是怎么拼出来的
|
|
20
|
+
|
|
21
|
+
每个门控返回 `{ findings, score, fatal? }`。整条链:
|
|
22
|
+
|
|
23
|
+
1. 按顺序运行每个门控,累积发现(每个门控都能看到 `prior`);
|
|
24
|
+
2. 把各门控的评分**相乘**——综合评分是乘积,所以一个坏门控会把整台服务器拉下来,而不是被三个好门控平均掉;
|
|
25
|
+
3. 若任一门控返回 `fatal`,或任一非 advisory 的发现达到 `policy.blockAtSeverity`,则阻止;
|
|
26
|
+
4. **只有**遇到显式 `fatal` 才短路。会阻止但不 fatal 的发现仍让后续门控继续报告,这样「为什么」的记录才完整。
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
const SEVERITY_RANK = { info: 0, low: 1, medium: 2, high: 3, critical: 4 };
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
如果 `policy.blockAtSeverity` 不是这五个键之一,构造函数会写一条警告并回退到 `"high"`。这里的一个拼写错误
|
|
33
|
+
曾是最糟糕的故障模式:`rank >= undefined` 在任何比较中都为 `false`,于是一个拼错的阈值会悄无声息地把阻止功能
|
|
34
|
+
整个关掉。
|
|
35
|
+
|
|
36
|
+
### 两个维度:严重级别与层级
|
|
37
|
+
|
|
38
|
+
严重级别回答的是*这值得多少注意*。**层级**(tier)回答的是*这到底算不算缺陷*——而它是发现上的一项数据
|
|
39
|
+
(`advisory: true`),不是严重级别的推论。
|
|
40
|
+
|
|
41
|
+
带 `advisory` 的发现会被报告,但在**任何** `blockAtSeverity` 下都不会阻止连接、也不会牺牲任何工具。一个 schema
|
|
42
|
+
里接受 `api_key` 的工具值得被指出来,但它不是缺陷;用「降低严重级别」来表达这一点,会让它对任何收紧阈值的人重新
|
|
43
|
+
变成阻止性的。
|
|
44
|
+
|
|
45
|
+
## static-scan
|
|
46
|
+
|
|
47
|
+
对每个工具的 `name`、`description` 与 `inputSchema` 做本地正则扫描。规则集 **v7** 共 26 条规则(其中 15 条带有上下文 **guard**——一项具名检查,用于判断某次命中是否真是该规则要找的东西;参见[实地普查](mcp-survey.zh.md),即校准 v4 的 1 108 台服务器实测):
|
|
48
|
+
15 条 `block`,11 条 `advise`。
|
|
49
|
+
|
|
50
|
+
**v5:任何规则读取文本之前,先对文本做归一化。** NFKC 把全角字母、连字和其他兼容形式变成普通字母;单词内部的不可见字符被删除;Unicode 标签区块(ASCII 的不可见副本,能藏下一整句话)被解码;在拉丁字母与西里尔或希腊字母混写的单词里,形近字母被换成拉丁字母,而整词只用一种文字写成的单词保持不变。于是,无论周围文本是什么语言,英文规则都不能再被全角字母、词内零宽空格、标签字符或西里尔字母 `о` 绕过。归一化以 `fold` 的形式与规则一起发布,并计入 digest。两条隐藏载荷规则读取**原始**文本(`raw: true`)。
|
|
51
|
+
|
|
52
|
+
正则表格读不懂含义:用规则没有覆盖的语言写成的说法不在它的范围内。v5 增加了与语言无关的部分:归一化;`TOOL_DEF_SECRET_EXFIL` 组合(机密存放位置与外部地址相距不超过 100 个字符;仅提示,因为它在 10 645 台真实服务器上唯一的命中是正当用途);`TOOL_DEF_HIDDEN_UNICODE` 纳入标签区块和双向隔离符;HISTOR 会标出服务器定义中新出现的任何外部地址。按含义识别属于分类器的工作,而不是这张表的工作。
|
|
53
|
+
|
|
54
|
+
v5 还消除了三类实测误报:“send the user to https://…”(引导用户跳转,guard `navigation`)、“keep calling … without asking the user”(自主运行,guard `autonomy`),以及表情符号序列中的零宽连接符。在 10 645 台服务器的语料上,v4 阻止 63 台,v5 阻止 56 台,且没有新增任何 v4 未阻止的服务器。
|
|
55
|
+
|
|
56
|
+
**v7** 消除了规则集 v6 在已提交的 2026-10-01 语料上仍有的三处误报([实地普查](mcp-survey.zh.md))。“Private key/value memory” 指键值存储,而非私钥(guard `keyValue`:以斜杠或连字符构成的复合词,后接表示存储的名词)。“Find … without asking the user for ids” 是工具自行解析标识符(guard `autonomy` 现在也接受查找类动词,前提是被索要的对象整体就是标识符)。“The key is read from the MCP connection's X-API-Key header” 是服务器在描述自己的认证方式(guard `ownAuthHeader`:被动语态 “is read from”、来源是请求头、不是他人的请求头、密钥未被转交他处)。此外,当 “without asking the user” 的对象是“同意”时,`autonomy` 不再豁免:“keep retrying the transfer without asking the user for approval” 重新被阻止。在该语料上,v6 阻止 6 台服务器,v7 阻止 3 台,其余所有发现均不变。
|
|
57
|
+
|
|
58
|
+
每条规则都声明自己在这三个**面**中的哪些上运行,26 条里有 17 条包含名称。不包含名称的那四条是以**名词**为
|
|
59
|
+
锚的规则(`TOOL_DEF_SECRET_REQUEST`、`TOOL_DEF_CREDENTIAL_PARAM`、`TOOL_DEF_ENV_REFERENCE`、`TOOL_DEF_SECRET_EXFIL`):名称是标识
|
|
60
|
+
符,`api_key` 和 `private_key` 是标识符里再普通不过的组成部分,而拒绝 `sign_with_private_key` 等于把 v1 的
|
|
61
|
+
校准错误换个面重犯一次。以**短语**为锚的规则需要空白字符,因此根本无法匹配 `snake_case`;而两条隐藏载荷规则
|
|
62
|
+
针对的字符在名称里从来都不合法——这些都在所有面上运行。
|
|
63
|
+
|
|
64
|
+
在 v3 之前,名称**根本没有被任何规则扫描过**:注入短语、零宽字符或 base64 串出现在模型最先读到的那个字段里,
|
|
65
|
+
完全不会被报告。
|
|
66
|
+
|
|
67
|
+
门控评分为 `1 − 惩罚(最严重的阻止性 severity)`;advisory 命中永远不影响它。
|
|
68
|
+
|
|
69
|
+
| 最严重的阻止性 severity | 无 | info | low | medium | high | critical |
|
|
70
|
+
|---|---|---|---|---|---|---|
|
|
71
|
+
| 门控评分 | 1 | 1 | 0.9 | 0.7 | 0.4 | 0 |
|
|
72
|
+
|
|
73
|
+
| 代码 | 严重级别 | 层级 | 名称? | 捕捉什么 |
|
|
74
|
+
|---|---|---|---|---|
|
|
75
|
+
| `TOOL_DEF_INJECTION` | critical / high | block | ✅ | 「ignore all previous instructions」「do not tell the user」、`<system>` 标签、对 developer prompt 的引用 |
|
|
76
|
+
| `TOOL_DEF_SECRET_REQUEST` | critical | block | — | `private_key`、`seed_phrase`/`mnemonic`、`~/.ssh` 路径 |
|
|
77
|
+
| `TOOL_DEF_SECRET_HARVEST` | critical | block | ✅ | 自称职责就是读取/导出/披露密钥的工具 |
|
|
78
|
+
| `TOOL_DEF_EXFIL` | critical / high | block | ✅ | 「post to https://…」「forward it to…」「exfiltrate」、上传到某主机的措辞 |
|
|
79
|
+
| `TOOL_DEF_HIDDEN_UNICODE` | high | block | ✅ | 零宽字符与双向控制字符——审阅者看不见的文本 |
|
|
80
|
+
| `TOOL_DEF_BASE64_BLOB` | high | block | ✅ | 名称、描述或 schema 里长达 120+ 字符的 base64 串 |
|
|
81
|
+
| `TOOL_DEF_DATA_URL` | high | block | ✅ | `data:…;base64,` 与 `javascript:` 形式的 URL |
|
|
82
|
+
| `TOOL_DEF_CREDENTIAL_PARAM` | medium / low | advise | — | schema 或描述索要 `api_key`、`password`、`secret`、bearer 令牌 |
|
|
83
|
+
| `TOOL_DEF_ENV_REFERENCE` | medium | advise | — | `.env`、「environment variables」 |
|
|
84
|
+
| `TOOL_DEF_IMPERATIVE` | low / info | advise | ✅ | 「you must」「instead of」——提示词形态的措辞,单独并不能证明任何事 |
|
|
85
|
+
|
|
86
|
+
`staticScanRuleset()` 会连同**正则源码、flags 与所扫描的面** 一起返回每条规则,好让第三方能重跑一模一样的规则;同时返回
|
|
87
|
+
`{ version, digest }`,其中 digest 是对已排序规则列表的 RFC 8785 规范化形式做的 sha256。排序按 code-unit 比较,
|
|
88
|
+
绝不用 `localeCompare`:依赖 locale 的排序会让同一张规则表在配置不同的主机上算出不同的摘要,而这正是摘要本身
|
|
89
|
+
要检测的分歧。
|
|
90
|
+
|
|
91
|
+
## threat-feed
|
|
92
|
+
|
|
93
|
+
把服务器身份和工具定义与 `ThreatRecord` 比对——11 条内置,加上已签名 feed 追加的内容(见
|
|
94
|
+
[feed 契约](threat-feed.zh.md))。
|
|
95
|
+
|
|
96
|
+
- 任何命中 ⇒ 门控评分 **0**。
|
|
97
|
+
- **只有**当 `critical` 记录命中*服务器*时才 `fatal`。命中某个*工具*的 critical 不是 fatal,于是链上其余门控
|
|
98
|
+
仍会报告,责任也仍限定在那个工具上——这正是让一台大体正常的服务器在隔离掉一个工具后继续可用的原因。
|
|
99
|
+
- `ThreatRecord.scope` 选择比对面:`server`(id/name/url/command/args)、`tool`
|
|
100
|
+
(name/description/inputSchema),或 `any`——记录省略该字段时的默认值。
|
|
101
|
+
|
|
102
|
+
内置代码:`THREAT_TYPOSQUAT`、`THREAT_CRYPTO_DRAINER`、`THREAT_SEED_PHRASE`、`THREAT_SSH_KEY_READ`、
|
|
103
|
+
`THREAT_ENV_EXFIL`、`THREAT_DESTRUCTIVE_CMD`、`THREAT_FORK_BOMB`。
|
|
104
|
+
|
|
105
|
+
## origin
|
|
106
|
+
|
|
107
|
+
这台服务器是运营者声明过的,还是来自远端目录(`McpServerRef.catalog` 有值)?
|
|
108
|
+
|
|
109
|
+
| `allowUnknownServers` | 发现 | 评分 | fatal |
|
|
110
|
+
|---|---|---|---|
|
|
111
|
+
| `false`(失败即关闭) | `SERVER_UNDECLARED`,high | 0 | 是 |
|
|
112
|
+
| `true` | `SERVER_UNDECLARED`,info | 1 | 否 |
|
|
113
|
+
|
|
114
|
+
这个开关过去的含义是「还没有信誉评分」,而没有任何部署能满足它:从来没有人给预言机提供过信任边,于是每台服务器
|
|
115
|
+
回来都是「无人担保」,`false` 就把它们全都拦住了。而「来自目录」是宿主本地已经掌握的事实,不需要网络,也不可能
|
|
116
|
+
死锁。
|
|
117
|
+
|
|
118
|
+
## pinning
|
|
119
|
+
|
|
120
|
+
把当前的工具定义与用户批准过的快照做比较。哈希是对定义集合的 RFC 8785 规范化形式做的 sha256——与 feed 签名用的是
|
|
121
|
+
同一套规范化,而不是第二种序列化。
|
|
122
|
+
|
|
123
|
+
| 情形 | 代码 | 严重级别 | 评分 | Fatal |
|
|
124
|
+
|---|---|---|---|---|
|
|
125
|
+
| 尚无 pin(首次接触) | `TOOL_DEF_UNPINNED` | info | 0.9 | 否 |
|
|
126
|
+
| 哈希与 pin 不一致 | `TOOL_DEF_DRIFT` | high | 0 | 当 `pinToolDefs` |
|
|
127
|
+
| 工具定义没有规范形式(无 pin) | `TOOL_DEF_UNCANONICAL` | medium | 0.5 | 否 |
|
|
128
|
+
| 工具定义没有规范形式(有 pin) | `TOOL_DEF_UNCANONICAL` | high | 0 | 当 `pinToolDefs` |
|
|
129
|
+
|
|
130
|
+
首次接触的代价是 0.1,而不是阻止:一台干净、已声明、尚无 pin 的服务器恰好得 **0.9**,而 `TOOL_DEF_UNPINNED`
|
|
131
|
+
被刻意定为 `info`——在 `blockAtSeverity: "info"` 下,若首次相见就阻止,那么任何服务器都将永远不可用,因为在被
|
|
132
|
+
批准过一次之前,什么都无法固定。
|
|
133
|
+
|
|
134
|
+
`warden.approve(server, tools)` 通过你的 `PinStore` 写入 pin。该操作是幂等的。
|
|
135
|
+
|
|
136
|
+
## 按工具划分
|
|
137
|
+
|
|
138
|
+
`allowedTools` / `blockedTools` 把已公布的工具分成两部分:
|
|
139
|
+
|
|
140
|
+
- 若某条非 advisory 的发现点名了某个工具(`finding.tool`)并达到阈值,则该工具**被阻止**;
|
|
141
|
+
- 其余工具全部允许;
|
|
142
|
+
- 敏感工具(`policy.sensitiveToolPatterns`)仍然是*被允许的*——它们只是被标记出来,好让你的智能体主循环在运行时
|
|
143
|
+
对每次调用要求审批。参见 `classifyTools` / `isSensitiveTool`。
|
|
144
|
+
|
|
145
|
+
## 新增一个门控
|
|
146
|
+
|
|
147
|
+
`WardenGate` 只是三行接口,而 `new Warden({ gates, policy, log })` 直接接受整条链,所以你可以插入自己的门控而
|
|
148
|
+
不必 fork:
|
|
149
|
+
|
|
150
|
+
```ts
|
|
151
|
+
import { Warden, StaticScanGate, ThreatGate, OriginGate, PinningGate } from "@aimarket/warden";
|
|
152
|
+
import type { WardenGate, WardenGateInput, WardenGateResult } from "@aimarket/warden";
|
|
153
|
+
|
|
154
|
+
class DenyByPublisher implements WardenGate {
|
|
155
|
+
readonly name = "publisher-allowlist";
|
|
156
|
+
async evaluate(input: WardenGateInput): Promise<WardenGateResult> {
|
|
157
|
+
const ok = ALLOWED.has(input.server.name);
|
|
158
|
+
return ok
|
|
159
|
+
? { findings: [], score: 1 }
|
|
160
|
+
: { findings: [{ gate: this.name, severity: "high", code: "PUBLISHER_UNKNOWN",
|
|
161
|
+
message: `${input.server.name} 不在允许的发布者名单中` }],
|
|
162
|
+
score: 0, fatal: true };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const warden = new Warden({
|
|
167
|
+
gates: [new StaticScanGate(), new ThreatGate(feed), new DenyByPublisher(), new OriginGate(), new PinningGate(store)],
|
|
168
|
+
policy,
|
|
169
|
+
});
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
自己写门控时有两条规矩:**没有真的发出请求,就绝不宣称远端服务不可达**
|
|
173
|
+
(`test/no-phantom-gate.test.ts` 会对随包发布的门控强制这一点);以及返回一个你能站得住脚的评分——什么都没测量的
|
|
174
|
+
门控必须返回 `1`,而不是一个「中性」的 0.6,否则它就是在为一次从未做过的测量惩罚每一台服务器。
|
|
175
|
+
|
|
176
|
+
## wrap 模式的检查流程
|
|
177
|
+
|
|
178
|
+
启动子进程前调用 `vetLaunch`。`initialize.instructions` 使用现有 static-scan,遇到阻止判定时删除。`tools/list` 最多读取 32 页、256 个唯一工具及 1 MiB,然后调用 `vet`;允许的定义以单页返回。`list_changed` 立即隔离服务器,只有重新检查成功才转发。每次调用前重新读取列表,与客户端最后看到的定义比较。检查期间变更阻止发送;执行期间变更则隐藏响应。透传消息另有 32 MiB 上限,内部请求超时为 10 秒。EOF 关闭子进程 stdin,5 秒后 SIGTERM,10 秒前 SIGKILL。服务器反向请求及其他消息保留 ID 和原始 JSON。策略、TOFU、人工审批及 audit-only 限制参见 README。
|