@aimarket/warden 0.0.1 → 0.4.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 +206 -0
- package/LICENSE +21 -0
- package/README-es.md +190 -0
- package/README-fr.md +190 -0
- package/README-ru.md +189 -0
- package/README-zh.md +173 -0
- package/README.md +179 -6
- package/dist/glob.d.ts +81 -0
- package/dist/glob.d.ts.map +1 -0
- package/dist/glob.js +180 -0
- package/dist/glob.js.map +1 -0
- package/dist/index.d.ts +106 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +218 -0
- package/dist/index.js.map +1 -0
- package/dist/jcs.d.ts +86 -0
- package/dist/jcs.d.ts.map +1 -0
- package/dist/jcs.js +296 -0
- package/dist/jcs.js.map +1 -0
- package/dist/logger.d.ts +10 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +19 -0
- package/dist/logger.js.map +1 -0
- package/dist/origin.d.ts +25 -0
- package/dist/origin.d.ts.map +1 -0
- package/dist/origin.js +49 -0
- package/dist/origin.js.map +1 -0
- package/dist/pinning.d.ts +91 -0
- package/dist/pinning.d.ts.map +1 -0
- package/dist/pinning.js +227 -0
- package/dist/pinning.js.map +1 -0
- package/dist/sandbox.d.ts +38 -0
- package/dist/sandbox.d.ts.map +1 -0
- package/dist/sandbox.js +79 -0
- package/dist/sandbox.js.map +1 -0
- package/dist/sanitize.d.ts +38 -0
- package/dist/sanitize.d.ts.map +1 -0
- package/dist/sanitize.js +53 -0
- package/dist/sanitize.js.map +1 -0
- package/dist/static-scan.d.ts +110 -0
- package/dist/static-scan.d.ts.map +1 -0
- package/dist/static-scan.js +539 -0
- package/dist/static-scan.js.map +1 -0
- package/dist/threat-feed.d.ts +138 -0
- package/dist/threat-feed.d.ts.map +1 -0
- package/dist/threat-feed.js +546 -0
- package/dist/threat-feed.js.map +1 -0
- package/dist/types.d.ts +173 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +11 -0
- package/dist/types.js.map +1 -0
- package/package.json +67 -9
- package/index.js +0 -6
package/dist/pinning.js
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { canonicalize, CanonicalizationError } from "./jcs.js";
|
|
3
|
+
import { displaySafe } from "./sanitize.js";
|
|
4
|
+
/**
|
|
5
|
+
* Tool-definition pinning + drift detection ("rug-pull" defence).
|
|
6
|
+
*
|
|
7
|
+
* A server can advertise benign tools at approval time and silently swap in a
|
|
8
|
+
* poisoned definition later. We hash the canonical tool-def set on approval and
|
|
9
|
+
* compare on every subsequent connection: a changed hash means the contract the
|
|
10
|
+
* user approved no longer holds, so (under policy.pinToolDefs) we block and force
|
|
11
|
+
* re-approval. First-contact servers are flagged UNPINNED so the chain knows the
|
|
12
|
+
* pin is established only when the user approves.
|
|
13
|
+
*/
|
|
14
|
+
export class PinningGate {
|
|
15
|
+
store;
|
|
16
|
+
name = "pinning";
|
|
17
|
+
constructor(store) {
|
|
18
|
+
this.store = store;
|
|
19
|
+
}
|
|
20
|
+
async evaluate(input) {
|
|
21
|
+
const pin = await this.store.getPin(input.server.id);
|
|
22
|
+
let hash;
|
|
23
|
+
try {
|
|
24
|
+
hash = canonicalToolsHash(input.tools);
|
|
25
|
+
}
|
|
26
|
+
catch (err) {
|
|
27
|
+
if (!(err instanceof CanonicalizationError))
|
|
28
|
+
throw err;
|
|
29
|
+
return this.uncanonical(input, pin, err);
|
|
30
|
+
}
|
|
31
|
+
const identityDrift = this.identityDrift(input, pin);
|
|
32
|
+
if (!pin) {
|
|
33
|
+
const finding = {
|
|
34
|
+
gate: this.name,
|
|
35
|
+
severity: "info",
|
|
36
|
+
code: "TOOL_DEF_UNPINNED",
|
|
37
|
+
message: `Server "${input.server.id}" has no pinned tool-def snapshot yet; it will be pinned on approval.`,
|
|
38
|
+
// Report-only by necessity, not by preference: a server at first contact
|
|
39
|
+
// cannot be anything but unpinned, and Warden.approve() runs only after
|
|
40
|
+
// vet() passes. Letting this finding block — which it did at
|
|
41
|
+
// blockAtSeverity "info" — makes first contact impossible for every
|
|
42
|
+
// server, so no pin can ever be created.
|
|
43
|
+
advisory: true,
|
|
44
|
+
};
|
|
45
|
+
// Neutral-to-good: unpinned isn't unsafe, it's just unestablished.
|
|
46
|
+
return { findings: [finding], score: 0.9 };
|
|
47
|
+
}
|
|
48
|
+
const findings = [];
|
|
49
|
+
if (pin.toolsHash !== hash) {
|
|
50
|
+
findings.push({
|
|
51
|
+
gate: this.name,
|
|
52
|
+
severity: "high",
|
|
53
|
+
code: "TOOL_DEF_DRIFT",
|
|
54
|
+
message: `Tool definitions for "${displaySafe(input.server.id)}" changed since approval ` +
|
|
55
|
+
`(pinned ${short(pin.toolsHash)} → now ${short(hash)}). Possible rug-pull; re-approval required.`,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
// Both are reported when both changed: "the tools moved AND so did the
|
|
59
|
+
// program serving them" is a different picture from either one alone, and
|
|
60
|
+
// returning on the first one found used to hide the second.
|
|
61
|
+
if (identityDrift)
|
|
62
|
+
findings.push(identityDrift);
|
|
63
|
+
if (findings.length > 0) {
|
|
64
|
+
return { findings, score: 0, fatal: input.policy.pinToolDefs === true };
|
|
65
|
+
}
|
|
66
|
+
return { findings: [], score: 1 };
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Has the program behind this server changed since approval?
|
|
70
|
+
*
|
|
71
|
+
* Silent when the pin predates {@link PinnedServer.identityHash}: an absent
|
|
72
|
+
* value is "not recorded", never "changed". The next `approve()` records it.
|
|
73
|
+
*/
|
|
74
|
+
identityDrift(input, pin) {
|
|
75
|
+
if (!pin?.identityHash)
|
|
76
|
+
return undefined;
|
|
77
|
+
const now = serverIdentityHash(input.server);
|
|
78
|
+
if (now === pin.identityHash)
|
|
79
|
+
return undefined;
|
|
80
|
+
return {
|
|
81
|
+
gate: this.name,
|
|
82
|
+
severity: "high",
|
|
83
|
+
code: "SERVER_IDENTITY_DRIFT",
|
|
84
|
+
message: `The launch identity of "${displaySafe(input.server.id)}" changed since approval ` +
|
|
85
|
+
`(pinned ${short(pin.identityHash)} → now ${short(now)}): transport, command, args, url or name ` +
|
|
86
|
+
`is not what was approved. The advertised tools may be identical while the program serving them is not.`,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* The tool-def set has no canonical form (see {@link canonicalToolsHash}), so no
|
|
91
|
+
* hash can be produced for it. What that means depends on whether a pin exists:
|
|
92
|
+
*
|
|
93
|
+
* - **No pin yet** — nothing is being contradicted; the pin simply cannot be
|
|
94
|
+
* established, which is a `medium` warning and not evidence of an attack.
|
|
95
|
+
* - **Pin exists** — the snapshot the user approved can no longer be re-verified,
|
|
96
|
+
* which is indistinguishable from drift and is treated as drift. Otherwise a
|
|
97
|
+
* server could disarm the rug-pull defence at will by adding one fractional
|
|
98
|
+
* number to a schema.
|
|
99
|
+
*/
|
|
100
|
+
uncanonical(input, pin, err) {
|
|
101
|
+
if (!pin) {
|
|
102
|
+
return {
|
|
103
|
+
findings: [
|
|
104
|
+
{
|
|
105
|
+
gate: this.name,
|
|
106
|
+
severity: "medium",
|
|
107
|
+
code: "TOOL_DEF_UNCANONICAL",
|
|
108
|
+
message: `Tool definitions for "${displaySafe(input.server.id)}" have no canonical form (${err.message}), ` +
|
|
109
|
+
`so no reproducible pin can be taken — drift detection is unavailable for this server.`,
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
score: 0.5,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
findings: [
|
|
117
|
+
{
|
|
118
|
+
gate: this.name,
|
|
119
|
+
severity: "high",
|
|
120
|
+
code: "TOOL_DEF_UNCANONICAL",
|
|
121
|
+
message: `Tool definitions for "${displaySafe(input.server.id)}" have no canonical form (${err.message}), so the pinned ` +
|
|
122
|
+
`snapshot ${short(pin.toolsHash)} cannot be re-verified. Treated as drift; re-approval required.`,
|
|
123
|
+
},
|
|
124
|
+
],
|
|
125
|
+
score: 0,
|
|
126
|
+
fatal: input.policy.pinToolDefs === true,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Persist the current tool-def set as the trusted snapshot for this server.
|
|
131
|
+
* Called by Warden.approve() once a user has accepted the connection.
|
|
132
|
+
*
|
|
133
|
+
* Throws {@link CanonicalizationError} when the set has no canonical form; the
|
|
134
|
+
* caller (`McpHost.connect`) already reports a pin failure as a degraded
|
|
135
|
+
* rug-pull defence rather than failing the connection.
|
|
136
|
+
*/
|
|
137
|
+
async pin(server, tools) {
|
|
138
|
+
const pinned = {
|
|
139
|
+
serverId: server.id,
|
|
140
|
+
toolsHash: canonicalToolsHash(tools),
|
|
141
|
+
approvedAt: new Date().toISOString(),
|
|
142
|
+
toolNames: [...tools.map((t) => t.name)].sort(compareCodeUnits),
|
|
143
|
+
identityHash: serverIdentityHash(server),
|
|
144
|
+
};
|
|
145
|
+
await this.store.putPin(pinned);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Marker used where a tool-def hash is *recorded* rather than compared, and the set
|
|
150
|
+
* turned out to have no canonical form. It can never collide with a sha256 hex
|
|
151
|
+
* digest, so anything that later compares it simply reports a mismatch.
|
|
152
|
+
*/
|
|
153
|
+
export const UNCANONICAL_TOOLS_HASH = "uncanonical:non-canonical-tool-defs";
|
|
154
|
+
/**
|
|
155
|
+
* sha256 over the canonical tool-def set: tools sorted by name, each reduced to
|
|
156
|
+
* the security-relevant fields (name, description, schema), serialised with
|
|
157
|
+
* {@link canonicalize} — RFC 8785 (JCS) as profiled in `awr/SPEC.md` §4.
|
|
158
|
+
*
|
|
159
|
+
* This digest is quoted in receipts and re-checked elsewhere (`argus verify`, the
|
|
160
|
+
* sealed mandate), so it has to be reproducible by an implementation that is not
|
|
161
|
+
* this one. Two consequences:
|
|
162
|
+
*
|
|
163
|
+
* - **Ordering is by UTF-16 code unit, never `localeCompare`.** `localeCompare`
|
|
164
|
+
* depends on the host locale and ICU version — `["a", "B"]` sorts one way under
|
|
165
|
+
* `en-US` and another under the C locale — so a digest built on it is not even
|
|
166
|
+
* stable across two machines running this same code, let alone across languages.
|
|
167
|
+
* JavaScript's `<`/`>` on strings compare UTF-16 code units, which is exactly
|
|
168
|
+
* RFC 8785 §3.2.3's rule.
|
|
169
|
+
* - **Non-integer numbers are refused** (`AWR-CANON-001`, SPEC §4.3) rather than
|
|
170
|
+
* serialised. Whether `1` is an integer or a double is a language accident that
|
|
171
|
+
* silently changes the bytes, so a schema carrying e.g. `"multipleOf": 0.01` has
|
|
172
|
+
* no canonical form here and this function throws
|
|
173
|
+
* {@link CanonicalizationError}. Callers must handle that — refusing to emit a
|
|
174
|
+
* digest is honest; emitting one nobody else can reproduce is not.
|
|
175
|
+
*/
|
|
176
|
+
export function canonicalToolsHash(tools) {
|
|
177
|
+
const canonical = [...tools]
|
|
178
|
+
.sort((a, b) => compareCodeUnits(a.name, b.name))
|
|
179
|
+
.map((t) => ({
|
|
180
|
+
name: t.name,
|
|
181
|
+
description: t.description ?? "",
|
|
182
|
+
inputSchema: t.inputSchema ?? {},
|
|
183
|
+
}));
|
|
184
|
+
return createHash("sha256").update(canonicalize(canonical), "utf8").digest("hex");
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Like {@link canonicalToolsHash} but total: returns `undefined` instead of
|
|
188
|
+
* throwing, for call sites that record a hash rather than enforce one.
|
|
189
|
+
*/
|
|
190
|
+
export function tryCanonicalToolsHash(tools) {
|
|
191
|
+
try {
|
|
192
|
+
return canonicalToolsHash(tools);
|
|
193
|
+
}
|
|
194
|
+
catch (err) {
|
|
195
|
+
if (err instanceof CanonicalizationError)
|
|
196
|
+
return undefined;
|
|
197
|
+
throw err;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* sha256 over the server's launch identity: transport, command, args, url, name.
|
|
202
|
+
*
|
|
203
|
+
* Deliberately NOT the whole `McpServerRef`: `catalog` is provenance rather than
|
|
204
|
+
* identity (the origin gate is what has an opinion about it), and `env` holds
|
|
205
|
+
* secrets that must not be hashed into a value stored on disk next to the pin.
|
|
206
|
+
*
|
|
207
|
+
* Missing fields serialize as empty rather than being omitted, so a server that
|
|
208
|
+
* gains a `url` reads as a change instead of hashing the same as before.
|
|
209
|
+
*/
|
|
210
|
+
export function serverIdentityHash(server) {
|
|
211
|
+
const identity = {
|
|
212
|
+
transport: server.transport,
|
|
213
|
+
command: server.command ?? "",
|
|
214
|
+
args: server.args ?? [],
|
|
215
|
+
url: server.url ?? "",
|
|
216
|
+
name: server.name,
|
|
217
|
+
};
|
|
218
|
+
return createHash("sha256").update(canonicalize(identity), "utf8").digest("hex");
|
|
219
|
+
}
|
|
220
|
+
/** RFC 8785 §3.2.3 ordering: arrays of UTF-16 code units as unsigned integers. */
|
|
221
|
+
function compareCodeUnits(a, b) {
|
|
222
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
223
|
+
}
|
|
224
|
+
function short(hash) {
|
|
225
|
+
return hash.slice(0, 12);
|
|
226
|
+
}
|
|
227
|
+
//# sourceMappingURL=pinning.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pinning.js","sourceRoot":"","sources":["../src/pinning.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ToolDef, WardenPolicy } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Runtime policy layer for tool execution.
|
|
4
|
+
*
|
|
5
|
+
* NOTE: this is the JS-level policy layer, not a true sandbox. It classifies
|
|
6
|
+
* tools, gates sensitive calls behind explicit approval, and watches egress for
|
|
7
|
+
* a tool trying to phone home. OS-level process isolation
|
|
8
|
+
* (seccomp / Landlock on Linux, sandbox-exec on macOS) is the v2 hardening that
|
|
9
|
+
* confines the actual child process; until then these helpers are the boundary
|
|
10
|
+
* the agent loop enforces in-process.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* True if a tool name matches any sensitive pattern in the policy. Patterns use
|
|
14
|
+
* `*` as a wildcard and match case-insensitively (e.g. "*delete*", "*transfer*").
|
|
15
|
+
* Sensitive tools are still allowed to be advertised — they just require explicit
|
|
16
|
+
* per-call approval before WARDEN lets them run.
|
|
17
|
+
*/
|
|
18
|
+
export declare function isSensitiveTool(toolName: string, policy: WardenPolicy): boolean;
|
|
19
|
+
/** Split a server's advertised tools into sensitive vs. safe by policy. */
|
|
20
|
+
export declare function classifyTools(tools: ToolDef[], policy: WardenPolicy): {
|
|
21
|
+
sensitive: string[];
|
|
22
|
+
safe: string[];
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Outbound-request allowlist. Wrap any network egress a tool performs in
|
|
26
|
+
* `check()` — a tool that tries to reach a host outside the allowlist is the
|
|
27
|
+
* classic "phone home" exfiltration tell. Hostnames are matched case-insensitively
|
|
28
|
+
* and a leading "*." entry matches subdomains (e.g. "*.example.com").
|
|
29
|
+
*/
|
|
30
|
+
export declare class EgressGuard {
|
|
31
|
+
private readonly allow;
|
|
32
|
+
constructor(allowlist: string[]);
|
|
33
|
+
check(url: string): {
|
|
34
|
+
allowed: boolean;
|
|
35
|
+
reason?: string;
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
//# sourceMappingURL=sandbox.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sandbox.d.ts","sourceRoot":"","sources":["../src/sandbox.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAExD;;;;;;;;;GASG;AAEH;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,YAAY,GAAG,OAAO,CAG/E;AAED,2EAA2E;AAC3E,wBAAgB,aAAa,CAC3B,KAAK,EAAE,OAAO,EAAE,EAChB,MAAM,EAAE,YAAY,GACnB;IAAE,SAAS,EAAE,MAAM,EAAE,CAAC;IAAC,IAAI,EAAE,MAAM,EAAE,CAAA;CAAE,CAQzC;AAED;;;;;GAKG;AACH,qBAAa,WAAW;IACtB,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAW;gBAErB,SAAS,EAAE,MAAM,EAAE;IAI/B,KAAK,CAAC,GAAG,EAAE,MAAM,GAAG;QAAE,OAAO,EAAE,OAAO,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE;CAmB1D"}
|
package/dist/sandbox.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { wildcardMatch } from "./glob.js";
|
|
2
|
+
/**
|
|
3
|
+
* Runtime policy layer for tool execution.
|
|
4
|
+
*
|
|
5
|
+
* NOTE: this is the JS-level policy layer, not a true sandbox. It classifies
|
|
6
|
+
* tools, gates sensitive calls behind explicit approval, and watches egress for
|
|
7
|
+
* a tool trying to phone home. OS-level process isolation
|
|
8
|
+
* (seccomp / Landlock on Linux, sandbox-exec on macOS) is the v2 hardening that
|
|
9
|
+
* confines the actual child process; until then these helpers are the boundary
|
|
10
|
+
* the agent loop enforces in-process.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* True if a tool name matches any sensitive pattern in the policy. Patterns use
|
|
14
|
+
* `*` as a wildcard and match case-insensitively (e.g. "*delete*", "*transfer*").
|
|
15
|
+
* Sensitive tools are still allowed to be advertised — they just require explicit
|
|
16
|
+
* per-call approval before WARDEN lets them run.
|
|
17
|
+
*/
|
|
18
|
+
export function isSensitiveTool(toolName, policy) {
|
|
19
|
+
const name = toolName.toLowerCase();
|
|
20
|
+
return policy.sensitiveToolPatterns.some((pat) => globMatch(pat, name));
|
|
21
|
+
}
|
|
22
|
+
/** Split a server's advertised tools into sensitive vs. safe by policy. */
|
|
23
|
+
export function classifyTools(tools, policy) {
|
|
24
|
+
const sensitive = [];
|
|
25
|
+
const safe = [];
|
|
26
|
+
for (const tool of tools) {
|
|
27
|
+
if (isSensitiveTool(tool.name, policy))
|
|
28
|
+
sensitive.push(tool.name);
|
|
29
|
+
else
|
|
30
|
+
safe.push(tool.name);
|
|
31
|
+
}
|
|
32
|
+
return { sensitive, safe };
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Outbound-request allowlist. Wrap any network egress a tool performs in
|
|
36
|
+
* `check()` — a tool that tries to reach a host outside the allowlist is the
|
|
37
|
+
* classic "phone home" exfiltration tell. Hostnames are matched case-insensitively
|
|
38
|
+
* and a leading "*." entry matches subdomains (e.g. "*.example.com").
|
|
39
|
+
*/
|
|
40
|
+
export class EgressGuard {
|
|
41
|
+
allow;
|
|
42
|
+
constructor(allowlist) {
|
|
43
|
+
this.allow = allowlist.map((h) => h.trim().toLowerCase()).filter(Boolean);
|
|
44
|
+
}
|
|
45
|
+
check(url) {
|
|
46
|
+
let host;
|
|
47
|
+
try {
|
|
48
|
+
host = new URL(url).hostname.toLowerCase();
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return { allowed: false, reason: `Unparseable URL: ${url}` };
|
|
52
|
+
}
|
|
53
|
+
if (this.allow.length === 0) {
|
|
54
|
+
return { allowed: false, reason: `Egress blocked (empty allowlist): ${host}` };
|
|
55
|
+
}
|
|
56
|
+
for (const entry of this.allow) {
|
|
57
|
+
if (entry === host)
|
|
58
|
+
return { allowed: true };
|
|
59
|
+
if (entry.startsWith("*.")) {
|
|
60
|
+
const suffix = entry.slice(1); // ".example.com"
|
|
61
|
+
if (host.endsWith(suffix) && host.length > suffix.length)
|
|
62
|
+
return { allowed: true };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return { allowed: false, reason: `Host not in egress allowlist: ${host}` };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* `*`-glob match (case-insensitive). Inputs are matched as whole strings.
|
|
70
|
+
*
|
|
71
|
+
* The pattern comes from the operator's policy, the value from the server being
|
|
72
|
+
* inspected. Compiling `*` into `.*` and running a regex made that pair a denial
|
|
73
|
+
* of service: an operator pattern with a dozen wildcards plus a 200-character
|
|
74
|
+
* tool name took 89 SECONDS to answer "not sensitive". See ./glob.ts.
|
|
75
|
+
*/
|
|
76
|
+
function globMatch(pattern, value) {
|
|
77
|
+
return wildcardMatch(pattern.toLowerCase(), value);
|
|
78
|
+
}
|
|
79
|
+
//# sourceMappingURL=sandbox.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sandbox.js","sourceRoot":"","sources":["../src/sandbox.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,WAAW,CAAC;AAG1C;;;;;;;;;GASG;AAEH;;;;;GAKG;AACH,MAAM,UAAU,eAAe,CAAC,QAAgB,EAAE,MAAoB;IACpE,MAAM,IAAI,GAAG,QAAQ,CAAC,WAAW,EAAE,CAAC;IACpC,OAAO,MAAM,CAAC,qBAAqB,CAAC,IAAI,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,SAAS,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC,CAAC;AAC1E,CAAC;AAED,2EAA2E;AAC3E,MAAM,UAAU,aAAa,CAC3B,KAAgB,EAChB,MAAoB;IAEpB,MAAM,SAAS,GAAa,EAAE,CAAC;IAC/B,MAAM,IAAI,GAAa,EAAE,CAAC;IAC1B,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,IAAI,eAAe,CAAC,IAAI,CAAC,IAAI,EAAE,MAAM,CAAC;YAAE,SAAS,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;;YAC7D,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAC5B,CAAC;IACD,OAAO,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC;AAC7B,CAAC;AAED;;;;;GAKG;AACH,MAAM,OAAO,WAAW;IACL,KAAK,CAAW;IAEjC,YAAY,SAAmB;QAC7B,IAAI,CAAC,KAAK,GAAG,SAAS,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;IAC5E,CAAC;IAED,KAAK,CAAC,GAAW;QACf,IAAI,IAAY,CAAC;QACjB,IAAI,CAAC;YACH,IAAI,GAAG,IAAI,GAAG,CAAC,GAAG,CAAC,CAAC,QAAQ,CAAC,WAAW,EAAE,CAAC;QAC7C,CAAC;QAAC,MAAM,CAAC;YACP,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,oBAAoB,GAAG,EAAE,EAAE,CAAC;QAC/D,CAAC;QACD,IAAI,IAAI,CAAC,KAAK,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YAC5B,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,qCAAqC,IAAI,EAAE,EAAE,CAAC;QACjF,CAAC;QACD,KAAK,MAAM,KAAK,IAAI,IAAI,CAAC,KAAK,EAAE,CAAC;YAC/B,IAAI,KAAK,KAAK,IAAI;gBAAE,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;YAC7C,IAAI,KAAK,CAAC,UAAU,CAAC,IAAI,CAAC,EAAE,CAAC;gBAC3B,MAAM,MAAM,GAAG,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,iBAAiB;gBAChD,IAAI,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,IAAI,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC,MAAM;oBAAE,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;YACrF,CAAC;QACH,CAAC;QACD,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,iCAAiC,IAAI,EAAE,EAAE,CAAC;IAC7E,CAAC;CACF;AAED;;;;;;;GAOG;AACH,SAAS,SAAS,CAAC,OAAe,EAAE,KAAa;IAC/C,OAAO,aAAa,CAAC,OAAO,CAAC,WAAW,EAAE,EAAE,KAAK,CAAC,CAAC;AACrD,CAAC"}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Make untrusted text safe to *display*.
|
|
3
|
+
*
|
|
4
|
+
* Every finding WARDEN emits quotes something the other side chose: a tool name,
|
|
5
|
+
* a server id, a catalog name, the `reason` string out of a signed threat feed.
|
|
6
|
+
* Those strings are then printed to a terminal by the host CLI, rendered in a
|
|
7
|
+
* dashboard, and stored in receipts. Interpolating them raw makes the firewall
|
|
8
|
+
* itself the delivery vehicle for a terminal-control payload:
|
|
9
|
+
*
|
|
10
|
+
* tool name: <ESC>[2K<ESC>[1A + "overwritten ~/.ssh"
|
|
11
|
+
* finding: Tool "<ESC>[2K<ESC>[1Aoverwritten ~/.ssh": Server references ...
|
|
12
|
+
*
|
|
13
|
+
* Printed to a TTY that erases the current line and moves the cursor up, so the
|
|
14
|
+
* BLOCK line WARDEN had just written is overwritten by text the attacker chose.
|
|
15
|
+
* The same trick with zero-width and bidi characters makes a name read as
|
|
16
|
+
* something it is not.
|
|
17
|
+
*
|
|
18
|
+
* So control characters and invisible characters are replaced with a VISIBLE
|
|
19
|
+
* escape rather than stripped. Stripping would hide the very thing worth seeing:
|
|
20
|
+
* a name with an embedded U+202E should look suspicious in the report, not clean.
|
|
21
|
+
* Length is capped, because a finding message is a line of output, not somewhere
|
|
22
|
+
* a hostile server gets to paste a megabyte.
|
|
23
|
+
*
|
|
24
|
+
* This is display hygiene, not a security boundary. `WardenFinding.tool` keeps
|
|
25
|
+
* the RAW advertised name, because that is the key a host uses to filter its own
|
|
26
|
+
* tool list and a sanitized key would match nothing.
|
|
27
|
+
*/
|
|
28
|
+
/** Default cap for an interpolated fragment: long enough to be useful in a log line. */
|
|
29
|
+
export declare const DEFAULT_DISPLAY_MAX = 200;
|
|
30
|
+
/**
|
|
31
|
+
* Render `value` as a single-line, control-character-free fragment, escaping what
|
|
32
|
+
* cannot be shown and truncating past `max`.
|
|
33
|
+
*
|
|
34
|
+
* Exported so a host can apply it to the raw names it gets back in
|
|
35
|
+
* `allowedTools` / `blockedTools`, which are deliberately not sanitized.
|
|
36
|
+
*/
|
|
37
|
+
export declare function displaySafe(value: unknown, max?: number): string;
|
|
38
|
+
//# sourceMappingURL=sanitize.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sanitize.d.ts","sourceRoot":"","sources":["../src/sanitize.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAUH,wFAAwF;AACxF,eAAO,MAAM,mBAAmB,MAAM,CAAC;AAEvC;;;;;;GAMG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,OAAO,EAAE,GAAG,GAAE,MAA4B,GAAG,MAAM,CASrF"}
|
package/dist/sanitize.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Make untrusted text safe to *display*.
|
|
3
|
+
*
|
|
4
|
+
* Every finding WARDEN emits quotes something the other side chose: a tool name,
|
|
5
|
+
* a server id, a catalog name, the `reason` string out of a signed threat feed.
|
|
6
|
+
* Those strings are then printed to a terminal by the host CLI, rendered in a
|
|
7
|
+
* dashboard, and stored in receipts. Interpolating them raw makes the firewall
|
|
8
|
+
* itself the delivery vehicle for a terminal-control payload:
|
|
9
|
+
*
|
|
10
|
+
* tool name: <ESC>[2K<ESC>[1A + "overwritten ~/.ssh"
|
|
11
|
+
* finding: Tool "<ESC>[2K<ESC>[1Aoverwritten ~/.ssh": Server references ...
|
|
12
|
+
*
|
|
13
|
+
* Printed to a TTY that erases the current line and moves the cursor up, so the
|
|
14
|
+
* BLOCK line WARDEN had just written is overwritten by text the attacker chose.
|
|
15
|
+
* The same trick with zero-width and bidi characters makes a name read as
|
|
16
|
+
* something it is not.
|
|
17
|
+
*
|
|
18
|
+
* So control characters and invisible characters are replaced with a VISIBLE
|
|
19
|
+
* escape rather than stripped. Stripping would hide the very thing worth seeing:
|
|
20
|
+
* a name with an embedded U+202E should look suspicious in the report, not clean.
|
|
21
|
+
* Length is capped, because a finding message is a line of output, not somewhere
|
|
22
|
+
* a hostile server gets to paste a megabyte.
|
|
23
|
+
*
|
|
24
|
+
* This is display hygiene, not a security boundary. `WardenFinding.tool` keeps
|
|
25
|
+
* the RAW advertised name, because that is the key a host uses to filter its own
|
|
26
|
+
* tool list and a sanitized key would match nothing.
|
|
27
|
+
*/
|
|
28
|
+
/** Zero-width and bidi controls: invisible, therefore always escaped. */
|
|
29
|
+
const INVISIBLE = "\\u200B-\\u200F\\u202A-\\u202E\\u2060\\u2066-\\u2069\\uFEFF";
|
|
30
|
+
/** C0 controls, DEL, and C1 - the terminal-control range. */
|
|
31
|
+
const CONTROL = "\\u0000-\\u001F\\u007F-\\u009F";
|
|
32
|
+
const UNSAFE = new RegExp(`[${CONTROL}${INVISIBLE}]`, "g");
|
|
33
|
+
/** Default cap for an interpolated fragment: long enough to be useful in a log line. */
|
|
34
|
+
export const DEFAULT_DISPLAY_MAX = 200;
|
|
35
|
+
/**
|
|
36
|
+
* Render `value` as a single-line, control-character-free fragment, escaping what
|
|
37
|
+
* cannot be shown and truncating past `max`.
|
|
38
|
+
*
|
|
39
|
+
* Exported so a host can apply it to the raw names it gets back in
|
|
40
|
+
* `allowedTools` / `blockedTools`, which are deliberately not sanitized.
|
|
41
|
+
*/
|
|
42
|
+
export function displaySafe(value, max = DEFAULT_DISPLAY_MAX) {
|
|
43
|
+
const text = typeof value === "string" ? value : String(value ?? "");
|
|
44
|
+
const escaped = text.replace(UNSAFE, (ch) => {
|
|
45
|
+
const code = ch.codePointAt(0) ?? 0;
|
|
46
|
+
return `\\u${code.toString(16).toUpperCase().padStart(4, "0")}`;
|
|
47
|
+
});
|
|
48
|
+
if (escaped.length <= max)
|
|
49
|
+
return escaped;
|
|
50
|
+
// Report the real length: "this name is 4 MB long" is itself worth knowing.
|
|
51
|
+
return `${escaped.slice(0, max)}... [truncated, ${text.length} chars]`;
|
|
52
|
+
}
|
|
53
|
+
//# sourceMappingURL=sanitize.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sanitize.js","sourceRoot":"","sources":["../src/sanitize.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,yEAAyE;AACzE,MAAM,SAAS,GAAG,6DAA6D,CAAC;AAEhF,6DAA6D;AAC7D,MAAM,OAAO,GAAG,gCAAgC,CAAC;AAEjD,MAAM,MAAM,GAAG,IAAI,MAAM,CAAC,IAAI,OAAO,GAAG,SAAS,GAAG,EAAE,GAAG,CAAC,CAAC;AAE3D,wFAAwF;AACxF,MAAM,CAAC,MAAM,mBAAmB,GAAG,GAAG,CAAC;AAEvC;;;;;;GAMG;AACH,MAAM,UAAU,WAAW,CAAC,KAAc,EAAE,MAAc,mBAAmB;IAC3E,MAAM,IAAI,GAAG,OAAO,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,IAAI,EAAE,CAAC,CAAC;IACrE,MAAM,OAAO,GAAG,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,EAAE;QAC1C,MAAM,IAAI,GAAG,EAAE,CAAC,WAAW,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC;QACpC,OAAO,MAAM,IAAI,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,WAAW,EAAE,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,EAAE,CAAC;IAClE,CAAC,CAAC,CAAC;IACH,IAAI,OAAO,CAAC,MAAM,IAAI,GAAG;QAAE,OAAO,OAAO,CAAC;IAC1C,4EAA4E;IAC5E,OAAO,GAAG,OAAO,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,mBAAmB,IAAI,CAAC,MAAM,SAAS,CAAC;AACzE,CAAC"}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { RulesetRef, Severity, WardenGate, WardenGateInput, WardenGateResult, WardenLogger } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Static tool-definition scanner.
|
|
4
|
+
*
|
|
5
|
+
* The MCP tool *description* and *input schema* are attacker-controlled text the
|
|
6
|
+
* model reads as trusted instructions — this is the tool-poisoning / prompt-
|
|
7
|
+
* injection surface. We scan that text (never the live tool output) for known
|
|
8
|
+
* signatures before a single token of it reaches the model. Cheap, deterministic,
|
|
9
|
+
* and runs first so later gates can reason about its findings.
|
|
10
|
+
*
|
|
11
|
+
* Rules are TIERED, and the tier is what decides blocking — not severity:
|
|
12
|
+
*
|
|
13
|
+
* block — no plausible benign reading in a tool definition. Blocks at the
|
|
14
|
+
* policy threshold like any other finding.
|
|
15
|
+
* advise — legitimately appears in honest tool definitions. Reported, never
|
|
16
|
+
* blocks, never reduces the score, at any `blockAtSeverity`.
|
|
17
|
+
*
|
|
18
|
+
* Ruleset v1 had one tier, so `api_key` in a schema carried the same weight as
|
|
19
|
+
* "ignore all previous instructions". A GitHub-style server whose `create_issue`
|
|
20
|
+
* takes an `api_key` and mentions a personal access token scored 0.40 and had the
|
|
21
|
+
* tool blocked under the default policy — i.e. most real MCP servers were refused,
|
|
22
|
+
* which is how a scanner gets switched off entirely.
|
|
23
|
+
*/
|
|
24
|
+
type Tier = "block" | "advise";
|
|
25
|
+
/**
|
|
26
|
+
* Which part of a tool definition a rule is meaningful against.
|
|
27
|
+
*
|
|
28
|
+
* `name` is an IDENTIFIER, not prose, and that difference decides the table
|
|
29
|
+
* below. A rule that keys on a noun - `api_key`, `private_key`, `.env` - matches
|
|
30
|
+
* ordinary identifiers (`sign_with_private_key` is a plausible wallet tool), so
|
|
31
|
+
* running it over names would refuse honest servers on their naming convention:
|
|
32
|
+
* exactly the ruleset v1 mistake, one surface over. A rule that keys on a PHRASE
|
|
33
|
+
* needs whitespace and cannot match `snake_case` at all, and the two hidden-payload
|
|
34
|
+
* rules are about characters that are never legitimate in a name.
|
|
35
|
+
*
|
|
36
|
+
* Names were scanned by nothing at all until ruleset v3, which meant zero-width
|
|
37
|
+
* characters and a base64 blob could sit in the one field that reaches the model
|
|
38
|
+
* first and WARDEN reported nothing.
|
|
39
|
+
*/
|
|
40
|
+
type Surface = "name" | "description" | "inputSchema";
|
|
41
|
+
/**
|
|
42
|
+
* Ruleset version. Bump on ANY change to the table above — a scan result is only
|
|
43
|
+
* comparable to another scan made under the same version and digest.
|
|
44
|
+
*
|
|
45
|
+
* 1 — single tier; credential parameter names blocked real servers.
|
|
46
|
+
* 2 — block/advise tiers; exfil "send … to" anchored on an external destination;
|
|
47
|
+
* TOOL_DEF_SECRET_HARVEST added so demoting the credential nouns does not
|
|
48
|
+
* open a hole; advisory findings no longer affect the score.
|
|
49
|
+
* 3 — rules gained `surfaces`, and the tool NAME became a scanned surface. Until
|
|
50
|
+
* v3 the name was scanned by nothing, so zero-width characters, a base64 blob
|
|
51
|
+
* or an injection phrase in the one field that reaches the model first went
|
|
52
|
+
* entirely unreported. Noun-keyed rules stay off the name on purpose: they
|
|
53
|
+
* match ordinary identifiers, and refusing `sign_with_private_key` would be
|
|
54
|
+
* the v1 calibration error committed on a new surface.
|
|
55
|
+
* 4 — calibrated against 1 108 live public MCP servers (docs/mcp-survey.md). v3
|
|
56
|
+
* blocked 50 of them and only 4 held up on review, so v4 adds context
|
|
57
|
+
* GUARDS — polarity, quotation, URI form, payload length, entropy, script
|
|
58
|
+
* adjacency — and demotes four blocking rules the survey showed were
|
|
59
|
+
* selecting for honest servers: the bare `exfiltrat*` noun, `system prompt`,
|
|
60
|
+
* `do not tell the user`, and (in severity only) the credential nouns. A
|
|
61
|
+
* rule's guards are part of this table and therefore of the digest.
|
|
62
|
+
*/
|
|
63
|
+
export declare const STATIC_SCAN_RULESET_VERSION = "4";
|
|
64
|
+
export interface StaticScanRule {
|
|
65
|
+
code: string;
|
|
66
|
+
severity: Severity;
|
|
67
|
+
tier: Tier;
|
|
68
|
+
/** Which surfaces the rule is run against, so a re-run scans the same fields. */
|
|
69
|
+
surfaces: Surface[];
|
|
70
|
+
/** The regex source, so a third party can re-run the exact rule. */
|
|
71
|
+
source: string;
|
|
72
|
+
flags: string;
|
|
73
|
+
/**
|
|
74
|
+
* Which context guards this rule is subject to. Published because the regex
|
|
75
|
+
* alone no longer describes the rule: the same pattern with and without
|
|
76
|
+
* `polarity` reports different findings on the same text.
|
|
77
|
+
*/
|
|
78
|
+
guards: string[];
|
|
79
|
+
}
|
|
80
|
+
export interface StaticScanRuleset extends RulesetRef {
|
|
81
|
+
rules: StaticScanRule[];
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The rule table plus its digest, so a scan result stays checkable after the
|
|
85
|
+
* rules change.
|
|
86
|
+
*
|
|
87
|
+
* Sorted, so the digest depends on the rules and not on the order they happen to
|
|
88
|
+
* be declared in — and sorted by CODE-UNIT comparison, not `localeCompare`: the
|
|
89
|
+
* digest is a cross-machine identifier, and a locale-dependent collation would
|
|
90
|
+
* make the same rule table digest differently on a differently-configured host,
|
|
91
|
+
* which is exactly the divergence the digest exists to detect. The preimage is
|
|
92
|
+
* the RFC 8785 canonical form (see ./jcs.ts) so this file does not invent a
|
|
93
|
+
* second serialization of its own.
|
|
94
|
+
*/
|
|
95
|
+
export declare function staticScanRuleset(): StaticScanRuleset;
|
|
96
|
+
/** Just the identity of the rule table, for embedding in a verdict. */
|
|
97
|
+
export declare function staticScanRulesetRef(): RulesetRef;
|
|
98
|
+
export declare class StaticScanGate implements WardenGate {
|
|
99
|
+
readonly name = "static-scan";
|
|
100
|
+
private readonly log;
|
|
101
|
+
/**
|
|
102
|
+
* @param log where dropped matches are reported. A guard silently discarding a
|
|
103
|
+
* finding is the one behaviour in this gate that cannot be seen in the
|
|
104
|
+
* verdict, so it is the one that most needs a debug line.
|
|
105
|
+
*/
|
|
106
|
+
constructor(log?: WardenLogger);
|
|
107
|
+
evaluate(input: WardenGateInput): Promise<WardenGateResult>;
|
|
108
|
+
}
|
|
109
|
+
export {};
|
|
110
|
+
//# sourceMappingURL=static-scan.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"static-scan.d.ts","sourceRoot":"","sources":["../src/static-scan.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EACV,UAAU,EACV,QAAQ,EAER,UAAU,EACV,eAAe,EACf,gBAAgB,EAChB,YAAY,EACb,MAAM,YAAY,CAAC;AAEpB;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,KAAK,IAAI,GAAG,OAAO,GAAG,QAAQ,CAAC;AAE/B;;;;;;;;;;;;;;GAcG;AACH,KAAK,OAAO,GAAG,MAAM,GAAG,aAAa,GAAG,aAAa,CAAC;AAuZtD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,eAAO,MAAM,2BAA2B,MAAM,CAAC;AAmB/C,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,QAAQ,CAAC;IACnB,IAAI,EAAE,IAAI,CAAC;IACX,iFAAiF;IACjF,QAAQ,EAAE,OAAO,EAAE,CAAC;IACpB,oEAAoE;IACpE,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd;;;;OAIG;IACH,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED,MAAM,WAAW,iBAAkB,SAAQ,UAAU;IACnD,KAAK,EAAE,cAAc,EAAE,CAAC;CACzB;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,iBAAiB,IAAI,iBAAiB,CAerD;AAOD,uEAAuE;AACvE,wBAAgB,oBAAoB,IAAI,UAAU,CAGjD;AAED,qBAAa,cAAe,YAAW,UAAU;IAC/C,QAAQ,CAAC,IAAI,iBAAiB;IAE9B,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAe;IAEnC;;;;OAIG;gBACS,GAAG,GAAE,YAA6B;IAIxC,QAAQ,CAAC,KAAK,EAAE,eAAe,GAAG,OAAO,CAAC,gBAAgB,CAAC;CAoDlE"}
|