@polygraphso/litmus 0.2.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.
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Egress sinkhole. Runs inside the egress-sniff container on an `--internal`
4
+ * Docker network. DNS answers every query with the sink's own IP, so the target
5
+ * dials back to us; the entrypoint's iptables REDIRECT funnels all inbound TCP
6
+ * (any port) to our listener, where we log `{host, port, firstBytes}` and drop
7
+ * the connection — never completing it. One `EGRESS {json}` line per attempt.
8
+ *
9
+ * KNOWN LIMIT (documented, v1): capture is DNS-ROUTED. A target that connects to
10
+ * a hard-coded IP literal — or uses DoH/DoT to a hard-coded resolver IP — issues
11
+ * no sinkholed lookup, so its packet is dropped by the `--internal` network and
12
+ * never reaches this listener: C-02 then reads as a false "no egress" pass. The
13
+ * real data still never leaves the box. Closing it needs DNS-independent capture
14
+ * (sink as default gateway + DNAT all egress) — roadmap. See
15
+ * docs/litmus-test-v1.md §7.
16
+ */
17
+
18
+ import dgram from "node:dgram";
19
+ import net from "node:net";
20
+
21
+ const TCP_SINK_PORT = 8443;
22
+ const emit = (rec) => process.stdout.write("EGRESS " + JSON.stringify(rec) + "\n");
23
+
24
+ function ipBytes() {
25
+ const parts = (process.env.SINK_IP || "203.0.113.1").trim().split(".").map((n) => parseInt(n, 10) & 0xff);
26
+ return parts.length === 4 && parts.every((n) => Number.isFinite(n)) ? parts : [203, 0, 113, 1];
27
+ }
28
+
29
+ // ── DNS (UDP 53): log the queried name, answer A with the sink's IP. ──────────
30
+ const udp = dgram.createSocket("udp4");
31
+ udp.on("message", (msg, rinfo) => {
32
+ try {
33
+ const name = decodeQName(msg, 12);
34
+ if (name) emit({ kind: "dns", host: name });
35
+ udp.send(dnsAnswer(msg), rinfo.port, rinfo.address);
36
+ } catch {
37
+ /* ignore malformed query */
38
+ }
39
+ });
40
+ udp.on("error", () => {});
41
+ udp.bind(53);
42
+
43
+ // ── TCP: all inbound TCP REDIRECTed here; log first bytes + sniffed host. ─────
44
+ const srv = net.createServer((sock) => {
45
+ sock.setTimeout(2000, () => sock.destroy());
46
+ sock.once("data", (buf) => {
47
+ emit({ kind: "tcp", host: sniffHost(buf), port: sock.localPort, firstBytes: printable(buf.subarray(0, 64)) });
48
+ sock.destroy();
49
+ });
50
+ sock.on("error", () => {});
51
+ });
52
+ srv.on("error", () => {});
53
+ srv.listen(TCP_SINK_PORT, "0.0.0.0");
54
+ process.stdout.write("EGRESS-SINK ready\n");
55
+
56
+ function printable(b) {
57
+ return b.toString("latin1").replace(/[^\x20-\x7e]/g, ".");
58
+ }
59
+
60
+ function decodeQName(msg, off) {
61
+ const labels = [];
62
+ let i = off;
63
+ while (i < msg.length) {
64
+ const len = msg[i];
65
+ if (len === 0 || (len & 0xc0) === 0xc0) break;
66
+ labels.push(msg.toString("ascii", i + 1, i + 1 + len));
67
+ i += 1 + len;
68
+ }
69
+ return labels.join(".");
70
+ }
71
+
72
+ function dnsAnswer(query) {
73
+ const id = query.subarray(0, 2);
74
+ const question = query.subarray(12); // question section (single-question queries)
75
+ const header = Buffer.from([id[0], id[1], 0x81, 0x80, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00]);
76
+ const [a, b, c, d] = ipBytes();
77
+ const answer = Buffer.from([0xc0, 0x0c, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x1e, 0x00, 0x04, a, b, c, d]);
78
+ return Buffer.concat([header, question, answer]);
79
+ }
80
+
81
+ function sniffHost(buf) {
82
+ const s = buf.toString("latin1");
83
+ const http = s.match(/host:\s*([^\r\n]+)/i);
84
+ if (http) return http[1].trim();
85
+ if (buf[0] === 0x16) {
86
+ const sni = s.match(/[a-z0-9-]+(?:\.[a-z0-9-]+)+\.[a-z]{2,}/i); // best-effort SNI
87
+ if (sni) return sni[0];
88
+ }
89
+ return undefined;
90
+ }
@@ -0,0 +1,594 @@
1
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
2
+ import { z } from 'zod';
3
+
4
+ /**
5
+ * Shared contract types for the litmus MVP. Web3-free.
6
+ *
7
+ * In-memory / evidence-bundle types are camelCase to match the canonical
8
+ * bundle JSON in `docs/onchain-proof-spec.md` §2. (Postgres row types added
9
+ * later for the discovery DB will be snake_case, mirroring the columns.)
10
+ */
11
+ /** Package registries a server ref can name. */
12
+ type Registry = "npm" | "pypi" | "github";
13
+ /** The methodology this build implements; embedded in every bundle + attestation.
14
+ * v2 adds C-02 probe 2.1 (declared-permission honesty), a new fail condition —
15
+ * a pass/fail-semantics change, so the version bumps per litmus-test §8. */
16
+ declare const METHODOLOGY_VERSION: "litmus-v2";
17
+ /** Evidence-bundle format version (owned by onchain-proof-spec §2).
18
+ * 1.1.0 adds the optional `harness.stdioIsolation` field and permits the
19
+ * disclaimer to vary by run mode; 1.0.0 bundles remain valid. */
20
+ declare const BUNDLE_SCHEMA_VERSION: "1.1.0";
21
+ type CategoryCode = "C-01" | "C-02" | "C-03" | "C-04";
22
+ /** Probe IDs carry their family number (1=injection, 2=permission, 4=sensitive). */
23
+ type ProbeId = "1.1" | "1.2" | "2.1" | "2.2" | "4.1" | "4.2";
24
+ type CategoryStatus = "pass" | "fail" | "skipped";
25
+ type ProbeStatus = "pass" | "fail" | "skipped" | "partial";
26
+ type LitmusGrade = "A" | "B" | "C" | "D" | "F";
27
+ type Severity = "low" | "medium" | "high";
28
+ /** uint8 encoding for per-category verdicts on the attestation (onchain-proof-spec §5). */
29
+ declare const CATEGORY_STATUS_UINT8: Record<CategoryStatus, number>;
30
+ type FindingKind = "invisible-unicode" | "instruction-mimicry" | "markdown-trick" | "canary" | "egress" | "permission-mislabel";
31
+ interface Finding {
32
+ kind: FindingKind;
33
+ severity: Severity;
34
+ /** The matched substring (or a hex dump, for invisible characters). */
35
+ match: string;
36
+ /** Byte offset where the match starts, when applicable. */
37
+ offset?: number;
38
+ /** Offending tool name, when the finding is tied to one. */
39
+ tool?: string;
40
+ host?: string;
41
+ port?: number;
42
+ firstBytes?: string;
43
+ }
44
+ interface ProbeResult {
45
+ id: ProbeId;
46
+ status: ProbeStatus;
47
+ findings: Finding[];
48
+ /** Skip/partial reason, when status is `skipped` or `partial`. */
49
+ reason?: string | null;
50
+ }
51
+ interface CategoryResult {
52
+ code: CategoryCode;
53
+ status: CategoryStatus;
54
+ reason?: string | null;
55
+ probes: ProbeResult[];
56
+ }
57
+ type TargetKind = "stdio" | "http";
58
+ interface TargetDescriptor {
59
+ kind: TargetKind;
60
+ /** stdio: the launched command (e.g. `npx -y <pkg>`). */
61
+ command?: string | null;
62
+ /** http: the remote MCP URL. */
63
+ url?: string | null;
64
+ }
65
+ /** The canonicalized fields of a tool that the fingerprint hashes. */
66
+ interface ToolDef {
67
+ name: string;
68
+ description: string;
69
+ inputSchema: unknown;
70
+ }
71
+ interface HarnessInfo {
72
+ package: string;
73
+ version: string;
74
+ node: string;
75
+ /** Governs C-02 / probe 4.2 applicability. */
76
+ dockerAvailable: boolean;
77
+ /** How a stdio target was executed (bundle 1.1.0). Set for stdio targets,
78
+ * omitted for http. "docker" = the target ran only inside the hardened
79
+ * container; "none" = launched on the host (the self-run default). */
80
+ stdioIsolation?: "docker" | "none";
81
+ }
82
+ interface EvidenceBundle {
83
+ schemaVersion: string;
84
+ methodologyVersion: string;
85
+ /** Canonical, versionless identity (serverKey). */
86
+ serverRef: string;
87
+ /** The exact version actually run. */
88
+ resolvedVersion: string | null;
89
+ target: TargetDescriptor;
90
+ /** sha256 of the canonical tool surface → `0x` + 64 hex (bytes32). */
91
+ toolDefsFingerprint: string;
92
+ /** The canonicalized {name, description, inputSchema} that was hashed. */
93
+ toolDefs: ToolDef[];
94
+ ranAt: string;
95
+ harness: HarnessInfo;
96
+ categories: CategoryResult[];
97
+ grade: LitmusGrade;
98
+ gradeRationale: string;
99
+ disclaimer: string;
100
+ }
101
+
102
+ /**
103
+ * Server-identity helpers for refs of the form `{registry}/{owner}/{name}@{version}`.
104
+ *
105
+ * Vendored verbatim from the `core` repo (`packages/core/src/identity.ts`) so
106
+ * this standalone repo has no cross-repo dependency. Keep in sync if core's
107
+ * parser changes.
108
+ *
109
+ * Examples:
110
+ * npm/@modelcontextprotocol/server-filesystem@0.4.2 (scoped npm)
111
+ * npm/lodash@4.17.21 (unscoped npm)
112
+ * pypi/mcp-server-git@1.0.0 (pypi — no owner)
113
+ * github/anthropic/mcp-server-foo@v0.1.3 (github — owner required)
114
+ *
115
+ * Owner rules per registry:
116
+ * - npm: optional (scoped packages have `@scope` as owner; unscoped omit it)
117
+ * - pypi: always absent (PyPI packages are flat — no owner namespacing)
118
+ * - github: required (always `{owner}/{repo}`)
119
+ *
120
+ * npm scopes are preserved (the `@` in `@modelcontextprotocol` belongs to the
121
+ * scope, not the version delimiter).
122
+ */
123
+
124
+ interface ParsedServerRef {
125
+ registry: Registry;
126
+ /** Null for unscoped npm and for all pypi refs; required for github. */
127
+ owner: string | null;
128
+ name: string;
129
+ version: string | null;
130
+ }
131
+ declare class ServerRefParseError extends Error {
132
+ constructor(ref: string, reason: string);
133
+ }
134
+ /**
135
+ * Parse a server ref. Version is optional; if present it must follow the final `@`.
136
+ * The owner segment may itself start with `@` (npm scope).
137
+ */
138
+ declare function parseServerRef(ref: string): ParsedServerRef;
139
+ declare function formatServerRef(parts: ParsedServerRef): string;
140
+ /** Identity of a server without a version pin. */
141
+ declare function serverKey(parts: Pick<ParsedServerRef, "registry" | "owner" | "name">): string;
142
+
143
+ /**
144
+ * Deterministic JSON for content-addressing the evidence bundle
145
+ * (onchain-proof-spec §2). Object keys are sorted lexicographically (recursively)
146
+ * so the same bundle always serializes to the same bytes → the same CID. Array
147
+ * order is preserved (the bundle already fixes it: categories by code, probes by
148
+ * ID). Raw string bytes are preserved (hidden-Unicode tampering must change the
149
+ * hash).
150
+ */
151
+ declare function canonicalStringify(value: unknown): string;
152
+
153
+ /**
154
+ * Connect to a target MCP server the way an agent would (technical-design §3).
155
+ *
156
+ * - a local package ref (`npm/…`, `pypi/…`) is launched as a subprocess over
157
+ * **stdio** (`npx -y` / `uvx`);
158
+ * - a remote `https://` URL is reached over **Streamable HTTP**;
159
+ * - an explicit `{command,args}` (for in-repo demo servers and tests) launches
160
+ * over stdio directly.
161
+ *
162
+ * Returns the connected `Client`, a descriptor for the evidence bundle, and a
163
+ * teardown. The normal MCP handshake (`initialize`) happens inside `connect()`.
164
+ */
165
+
166
+ interface StdioCommand$1 {
167
+ command: string;
168
+ args?: string[];
169
+ env?: Record<string, string>;
170
+ cwd?: string;
171
+ /** Friendly identity for the evidence bundle (defaults to the command line). */
172
+ serverRef?: string;
173
+ }
174
+ /** A litmus target: a server ref string, an https URL, or an explicit stdio command. */
175
+ type TargetInput = string | StdioCommand$1;
176
+ interface ConnectedTarget {
177
+ client: Client;
178
+ kind: TargetKind;
179
+ descriptor: TargetDescriptor;
180
+ /** Canonical versionless identity (serverKey), the URL, or the command line. */
181
+ serverRef: string;
182
+ resolvedVersion: string | null;
183
+ teardown: () => Promise<void>;
184
+ }
185
+ interface ConnectOptions {
186
+ /** Env to seed into a locally-launched server (e.g. canaries for C-03). */
187
+ seedEnv?: Record<string, string>;
188
+ /** Working directory to launch a local stdio server in (e.g. a canary-seeded cwd for C-03 4.1). */
189
+ seedCwd?: string;
190
+ /**
191
+ * HTTP request headers for a remote (`https://`) target — e.g.
192
+ * `{ Authorization: "Bearer …" }` to reach an OAuth-gated MCP server. Ignored
193
+ * for stdio targets (those authenticate via env). Sent only to the target
194
+ * origin (see `sameOriginAuthFetch`).
195
+ */
196
+ httpHeaders?: Record<string, string>;
197
+ /**
198
+ * stdio execution mode. "none" (default) launches the target on the host;
199
+ * "docker" runs an npm target ONLY inside the hardened container (§2.6) and
200
+ * throws IsolationUnsupportedError for any other stdio kind. http targets are
201
+ * unaffected (isolation is stdio-only).
202
+ */
203
+ isolation?: "none" | "docker";
204
+ /** Label every docker resource created here, so a killed parent can sweep. */
205
+ runLabel?: string;
206
+ }
207
+ declare function connectTarget(input: TargetInput, opts?: ConnectOptions): Promise<ConnectedTarget>;
208
+
209
+ /**
210
+ * The harness orchestrator (technical-design §3): connect → fingerprint →
211
+ * probes → grade → bundle. Public entry point: `runLitmus`.
212
+ */
213
+
214
+ /** Caller-supplied knobs for a litmus run. */
215
+ interface RunLitmusOptions {
216
+ /**
217
+ * HTTP request headers for a remote (`https://`) target — e.g. an
218
+ * `Authorization: Bearer …` to grade an OAuth-gated MCP server. Ignored for
219
+ * stdio targets.
220
+ */
221
+ headers?: Record<string, string>;
222
+ /**
223
+ * Actively call state-changing tools (`send`/`swap`/`sign`/`delete` …) too.
224
+ * Off by default: those tools are skipped from bait calls so the harness
225
+ * can't move money or mutate state on an authenticated server.
226
+ */
227
+ allowStateChanging?: boolean;
228
+ /**
229
+ * stdio execution mode. "docker" runs an npm target ONLY inside the hardened
230
+ * container and fails the run on any isolation failure (no fallback to host
231
+ * exec, no B-cap). Default: "docker" when `LITMUS_STDIO_ISOLATION=docker`,
232
+ * else "none".
233
+ */
234
+ isolation?: "none" | "docker";
235
+ /** Override the baked bundle disclaimer (e.g. the hosted operator-run string). */
236
+ disclaimer?: string;
237
+ /** Label every docker resource created by this run, so a killed parent can sweep. */
238
+ runLabel?: string;
239
+ /**
240
+ * Overall wall-clock ceiling (ms) for the whole probe sequence after connect.
241
+ * The per-step timeouts (connect, listTools, each tool call) bound individual
242
+ * calls, but their SUM is attacker-controlled: a hostile server can declare up
243
+ * to MAX_TOOLS tools and hang each call to its per-call timeout. This caps the
244
+ * aggregate so the in-process (`https`) path can't pin the caller for hours.
245
+ * Unset ⇒ no aggregate bound (the npm path is already bounded by the scrubbed
246
+ * child's process-group SIGKILL in executeRun). On timeout the run rejects and
247
+ * the `finally` tears the connection down, settling any in-flight calls.
248
+ */
249
+ timeoutMs?: number;
250
+ }
251
+ declare function runLitmus(target: TargetInput, opts?: RunLitmusOptions): Promise<EvidenceBundle>;
252
+
253
+ /**
254
+ * Tool-surface fingerprint (litmus-test-v1 §6, technical-design §3).
255
+ *
256
+ * Canonicalize the tool surface from `tools/list` and hash it to a bytes32:
257
+ * - keep {name, description, inputSchema} per tool
258
+ * - sort tools by name; recursively sort object keys
259
+ * - normalize ASCII whitespace in descriptions (trim/collapse) but KEEP raw
260
+ * Unicode — hidden-character injection must change the hash
261
+ * - JSON.stringify → sha256 → `0x` + 64 hex
262
+ *
263
+ * The grade certifies *this exact surface*; the consuming agent recomputes the
264
+ * live fingerprint before paying and refuses on any mismatch (rug-pull guard).
265
+ */
266
+
267
+ interface FingerprintResult {
268
+ /** `0x` + 64 lowercase hex (bytes32). */
269
+ fingerprint: string;
270
+ /** The canonicalized tool defs that were hashed (also stored in the bundle). */
271
+ canonical: ToolDef[];
272
+ }
273
+ declare function fingerprintToolDefs(tools: readonly ToolDef[]): FingerprintResult;
274
+
275
+ /**
276
+ * Grading rubric (litmus-test-v1 §5). A single letter A–F, always with a
277
+ * rationale (never a bare letter).
278
+ *
279
+ * F — any C-01 or C-03 failure (injection or data leak)
280
+ * D — C-02 failure (unexpected egress), no C-01/C-03 failure
281
+ * A — all three categories pass
282
+ * B — C-01 & C-03 pass, C-02 skipped (no sandbox / remote target)
283
+ *
284
+ * Robust to categories that haven't run yet (early milestones): if nothing
285
+ * failed and C-01 passed but some categories were skipped, it reports B and
286
+ * names what was not verified.
287
+ */
288
+
289
+ interface Grade {
290
+ grade: LitmusGrade;
291
+ rationale: string;
292
+ }
293
+ declare function gradeFromCategories(categories: readonly CategoryResult[]): Grade;
294
+
295
+ /**
296
+ * Assemble the canonical evidence bundle (onchain-proof-spec §2).
297
+ *
298
+ * Content-addressed: its CID is its hash, so the `reportCID` in the attestation
299
+ * pins this exact document. Canonicalization (sorted keys etc.) is applied when
300
+ * it's serialized for pinning; here we build the in-memory shape.
301
+ */
302
+
303
+ interface BundleInput {
304
+ serverRef: string;
305
+ resolvedVersion: string | null;
306
+ target: TargetDescriptor;
307
+ toolDefsFingerprint: string;
308
+ toolDefs: ToolDef[];
309
+ categories: CategoryResult[];
310
+ grade: Grade;
311
+ ranAt: string;
312
+ dockerAvailable: boolean;
313
+ /** How a stdio target was executed (bundle 1.1.0). Set for stdio targets;
314
+ * omitted for http (isolation is stdio-only). */
315
+ stdioIsolation?: "docker" | "none";
316
+ /** Override the baked disclaimer (e.g. the hosted operator-run string). The
317
+ * local self-run default is used when this is absent. */
318
+ disclaimer?: string;
319
+ }
320
+ declare function assembleBundle(input: BundleInput): EvidenceBundle;
321
+
322
+ /**
323
+ * Shared detection primitives for C-01 (injection) and C-03 (canaries),
324
+ * implemented once and applied uniformly (litmus-test-v1 §3). Pure functions
325
+ * over text — independently unit-testable; the place new failure modes are added.
326
+ *
327
+ * Each returns structured `Finding`s; probes decide pass/fail from severity.
328
+ */
329
+
330
+ declare function invisibleUnicode(text: string): Finding[];
331
+ declare function instructionMimicry(text: string): Finding[];
332
+ declare function markdownTricks(text: string): Finding[];
333
+ /**
334
+ * Exact and lightly-obfuscated match of planted canaries (litmus-v1 §3:
335
+ * "exact and lightly-obfuscated (case, whitespace, simple encodings)"). Beyond
336
+ * exact + case-insensitive, it defeats whitespace-splitting and simple encodings
337
+ * (base64/hex/url) by re-checking decoded/normalized views of the text. False
338
+ * positives are bounded by requiring the *full, distinctive* canary to appear in
339
+ * a view — random base64/hex won't decode to a `POLYGRAPH-CANARY-<uuid>`.
340
+ */
341
+ declare function canaryMatch(text: string, canaries: readonly string[]): Finding[];
342
+ /** True if any finding is high-severity (the C-01 fail bar). */
343
+ declare function hasHighSeverity(findings: readonly Finding[]): boolean;
344
+
345
+ /**
346
+ * Tool-safety classification (litmus-test-v1 §C-01/§C-03 safety note).
347
+ *
348
+ * The dynamic probes (C-01 1.2, C-03 4.1) call tools with bait inputs. Against
349
+ * an authenticated server that can move money or mutate state — a wallet's
350
+ * `send`/`swap`/`sign`, a database's `delete` — that is unacceptable. So by
351
+ * default we do NOT actively call tools classified as state-changing: they are
352
+ * still fingerprinted and statically scanned (1.1), but recorded as skipped,
353
+ * not exercised. `--allow-state-changing` opts back into full exercise.
354
+ *
355
+ * Classification is conservative (prefer skipping a safe tool over calling a
356
+ * dangerous one) and uses, in order: MCP tool annotations, then a verb
357
+ * heuristic on the name/description.
358
+ */
359
+ /** The MCP tool annotation hints we read (a subset of the spec's annotations). */
360
+ interface ToolAnnotations {
361
+ readOnlyHint?: boolean;
362
+ destructiveHint?: boolean;
363
+ }
364
+ interface ToolSafetyInput {
365
+ name: string;
366
+ description?: string;
367
+ annotations?: ToolAnnotations | null;
368
+ }
369
+ interface ToolSafety {
370
+ stateChanging: boolean;
371
+ /** Why it was classified state-changing (for the probe's skip reason). */
372
+ reason?: string;
373
+ }
374
+ /** Classify whether a tool should be skipped from active (bait-call) exercise. */
375
+ declare function classifyTool(tool: ToolSafetyInput): ToolSafety;
376
+ /** Names of the tools in a surface that are state-changing (skipped by default). */
377
+ declare function stateChangingToolNames(tools: readonly ToolSafetyInput[]): Set<string>;
378
+
379
+ /** What every probe receives: the live client, the tool surface, planted canaries. */
380
+ interface ProbeContext {
381
+ client: Client;
382
+ tools: ToolDef[];
383
+ /** Per-run unique canary strings (planted by C-03; available to all probes). */
384
+ canaries: string[];
385
+ /** Whether a network sandbox is available (governs C-02 / probe 4.2). */
386
+ dockerAvailable: boolean;
387
+ /**
388
+ * Tool names classified as state-changing (e.g. `send`/`swap`/`delete`). The
389
+ * dynamic probes skip actively calling these unless `allowStateChanging` is
390
+ * set — they still get the static scan (1.1). See `tool-safety.ts`.
391
+ */
392
+ stateChangingTools: ReadonlySet<string>;
393
+ /** When true, exercise every tool including state-changing ones. */
394
+ allowStateChanging: boolean;
395
+ }
396
+
397
+ /**
398
+ * Network constants (onchain-proof-spec §4). EAS contracts are OP-Stack
399
+ * predeploys — identical on Base and Base Sepolia. The schema UID is filled
400
+ * per-network after registration (env / web/lib).
401
+ */
402
+ type Network = "base-sepolia" | "base";
403
+ interface NetworkConfig {
404
+ chainId: number;
405
+ rpc: string;
406
+ eas: string;
407
+ schemaRegistry: string;
408
+ easscan: string;
409
+ }
410
+ declare const NETWORKS: Record<Network, NetworkConfig>;
411
+ declare function selectedNetwork(): Network;
412
+ declare function networkConfig(net?: Network): NetworkConfig;
413
+ /**
414
+ * The RPC URL for a network, honoring the per-network env override
415
+ * (`BASE_MAINNET_RPC_URL` for base, `BASE_SEPOLIA_RPC_URL` otherwise) when set
416
+ * and non-empty, else the baked public default. A hosted service needs its own
417
+ * RPC; `readAttestation` (read) goes through here.
418
+ */
419
+ declare function rpcUrl(net?: Network): string;
420
+
421
+ /**
422
+ * EAS attestation encoding for litmus grades (onchain-proof-spec §3).
423
+ *
424
+ * One schema, registered once per network; each litmus result is one attestation
425
+ * referencing the bundle CID. The heavy evidence stays off-chain (pinned by
426
+ * reportCID); on-chain we keep the fingerprint, per-category uint8 verdicts, the
427
+ * letter grade, the methodology version, and the resolved version the grade was
428
+ * run against (empty string when the target had no resolvable version).
429
+ */
430
+
431
+ declare const LITMUS_SCHEMA = "string serverRef,bytes32 toolDefsFingerprint,uint8 gradeC01,uint8 gradeC02,uint8 gradeC03,string overallGrade,string reportCID,string methodologyVersion,uint64 ranAt,string resolvedVersion";
432
+ interface LitmusAttestationFields {
433
+ serverRef: string;
434
+ toolDefsFingerprint: string;
435
+ gradeC01: number;
436
+ gradeC02: number;
437
+ gradeC03: number;
438
+ overallGrade: string;
439
+ reportCID: string;
440
+ methodologyVersion: string;
441
+ ranAt: bigint;
442
+ resolvedVersion: string;
443
+ }
444
+ declare function litmusFields(bundle: EvidenceBundle, reportCID: string): LitmusAttestationFields;
445
+ /** ABI-encode the attestation data for `eas.attest({ data })`. No network. */
446
+ declare function encodeLitmusAttestation(bundle: EvidenceBundle, reportCID: string): string;
447
+ declare function decodeLitmusAttestation(encoded: string): Record<string, unknown>;
448
+
449
+ /**
450
+ * Read a litmus attestation from chain (the trust-critical read — onchain-proof
451
+ * §7). Needs an RPC + a registered schema; the agent-gate calls this, then
452
+ * re-checks the live fingerprint before paying.
453
+ *
454
+ * [verify] eas-sdk EAS.getAttestation return shape (uid / data / revocationTime).
455
+ */
456
+ /** The registered litmus schema UID for the selected network (from env). */
457
+ declare function litmusSchemaUID(): string;
458
+ interface OnchainLitmusAttestation {
459
+ uid: string;
460
+ serverRef: string;
461
+ toolDefsFingerprint: string;
462
+ overallGrade: string;
463
+ reportCID: string;
464
+ /** The version the grade was run against; null for HTTP/unresolved targets
465
+ * (the on-chain empty-string sentinel is normalized to null here). */
466
+ resolvedVersion: string | null;
467
+ revoked: boolean;
468
+ /** Account that signed the attestation (self-mint model: any address). */
469
+ attester: string;
470
+ /** EAS expiry in unix seconds; 0n = no expiration. */
471
+ expirationTime: bigint;
472
+ }
473
+ declare function readAttestation(uid: string): Promise<OnchainLitmusAttestation | null>;
474
+
475
+ /**
476
+ * The agent payment-gate (technical-design §6, onchain-proof-spec §7).
477
+ *
478
+ * Before an agent trusts (and pays) an MCP server, it checks the polygraph
479
+ * cheapest-first:
480
+ * 1. no attestation → refuse
481
+ * 2. server-ref binding — the attestation must be FOR this server, not a
482
+ * grade-A attestation minted over a *different* server → refuse on mismatch
483
+ * 3. live-fingerprint check — recompute the live tool surface; if it ≠ the
484
+ * attested fingerprint → refuse (rug pull): the surface changed since it
485
+ * was graded
486
+ * 4. grade check — a failing grade → refuse, 0 spent
487
+ * All pass → pay.
488
+ *
489
+ * `gateDecision` is pure and unit-tested; `liveFingerprint` reuses the harness
490
+ * and returns the connected server's canonical ref so the binding compares
491
+ * apples to apples.
492
+ */
493
+
494
+ interface AttestationView {
495
+ /** Canonical ref the attestation was minted for (binds grade↔server). */
496
+ serverRef: string;
497
+ toolDefsFingerprint: string;
498
+ overallGrade: string;
499
+ /**
500
+ * The version the grade was run against, surfaced for the human/agent log.
501
+ * ADVISORY ONLY: there is no trustworthy live-version oracle, so this never
502
+ * affects the decision — the fingerprint is the sole cryptographic anchor.
503
+ */
504
+ resolvedVersion?: string | null;
505
+ revoked?: boolean;
506
+ /** EAS expiry in unix seconds; 0n / undefined = no expiration. */
507
+ expirationTime?: bigint;
508
+ }
509
+ interface LiveTarget {
510
+ fingerprint: string;
511
+ serverRef: string;
512
+ }
513
+ type GateAction = "pay" | "refuse";
514
+ interface GateDecision {
515
+ action: GateAction;
516
+ reason: string;
517
+ }
518
+ /** Grades an agent will transact with. F (injection/leak) and D (egress) are out. */
519
+ declare const DEFAULT_PASSING: Set<string>;
520
+ declare function gateDecision(attestation: AttestationView | null, live: LiveTarget, passing?: Set<string>, now?: bigint): GateDecision;
521
+ /**
522
+ * Recompute the live tool-surface fingerprint of a target (the mandatory
523
+ * call-time check) and return the connected server's canonical ref alongside it,
524
+ * so the gate can bind the attestation to the actual server.
525
+ */
526
+ declare function liveFingerprint(target: TargetInput): Promise<LiveTarget>;
527
+
528
+ /**
529
+ * `run_litmus` — run the open behavioral harness end-to-end against an MCP
530
+ * server and return the grade, the evidence, and (when an API URL is set) a mint
531
+ * hand-off URL. Brand-voiced: plain, exact, no overclaim.
532
+ *
533
+ * Unlike `verify_attestation` (a passive onchain read), this tool LAUNCHES the
534
+ * target server's code to exercise it — sandboxed for egress when Docker is
535
+ * present. It needs no wallet or RPC; only minting (which the human does in a
536
+ * browser via the returned URL) requires a wallet.
537
+ */
538
+
539
+ declare const RUN_LITMUS_TOOL_NAME = "run_litmus";
540
+ declare const RUN_LITMUS_TOOL_TITLE = "Run a behavioral litmus on an MCP server";
541
+ declare const RUN_LITMUS_TOOL_DESCRIPTION: string;
542
+ declare const runLitmusInputShape: {
543
+ server_ref: z.ZodString;
544
+ pin: z.ZodOptional<z.ZodBoolean>;
545
+ };
546
+ declare function handleRunLitmus({ server_ref, pin }: {
547
+ server_ref: string;
548
+ pin?: boolean;
549
+ }): Promise<{
550
+ content: {
551
+ type: "text";
552
+ text: string;
553
+ }[];
554
+ isError?: undefined;
555
+ } | {
556
+ isError: true;
557
+ content: {
558
+ type: "text";
559
+ text: string;
560
+ }[];
561
+ }>;
562
+
563
+ /**
564
+ * `polygraphso litmus <ref | https-url | path-to-mcp>` — run the behavioral
565
+ * harness locally and print the grade. The heavy harness (`@polygraph/probes`)
566
+ * is loaded lazily so the zero-dep `check`/`list` fast path stays intact.
567
+ */
568
+
569
+ type StdioCommand = {
570
+ command: string;
571
+ args: string[];
572
+ serverRef?: string;
573
+ };
574
+ interface ParsedLitmusFlags {
575
+ /** HTTP headers for a remote target (e.g. `Authorization: Bearer …`). */
576
+ headers: Record<string, string>;
577
+ /** Whether to actively call state-changing tools (opt-in). */
578
+ allowStateChanging: boolean;
579
+ /** Non-flag arguments, in order (positionals[0] is the target). */
580
+ positionals: string[];
581
+ }
582
+ /**
583
+ * Parse the litmus flags into HTTP headers + the state-changing opt-in, and
584
+ * separate out the positional target. Proper parsing (not just "first non-flag
585
+ * arg") matters because `--bearer <token>` takes a value that must not be
586
+ * mistaken for the target.
587
+ *
588
+ * Authorization precedence (last wins): `LITMUS_BEARER` < `--bearer` < `--header`.
589
+ */
590
+ declare function parseAuthFlags(args: readonly string[], env?: NodeJS.ProcessEnv): ParsedLitmusFlags;
591
+ /** A target is an https URL, a local MCP entry file, or a registry ref. */
592
+ declare function resolveTarget(target: string): string | StdioCommand;
593
+
594
+ export { type AttestationView, BUNDLE_SCHEMA_VERSION, type BundleInput, CATEGORY_STATUS_UINT8, type CategoryCode, type CategoryResult, type CategoryStatus, type ConnectOptions, type ConnectedTarget, DEFAULT_PASSING, type EvidenceBundle, type Finding, type FindingKind, type FingerprintResult, type GateAction, type GateDecision, type Grade, type HarnessInfo, LITMUS_SCHEMA, type LitmusAttestationFields, type LitmusGrade, type RunLitmusOptions as LitmusOptions, METHODOLOGY_VERSION, NETWORKS, type Network, type NetworkConfig, type OnchainLitmusAttestation, type ParsedLitmusFlags, type ParsedServerRef, type ProbeContext, type ProbeId, type ProbeResult, type ProbeStatus, RUN_LITMUS_TOOL_DESCRIPTION, RUN_LITMUS_TOOL_NAME, RUN_LITMUS_TOOL_TITLE, type Registry, type RunLitmusOptions, ServerRefParseError, type Severity, type StdioCommand, type TargetDescriptor, type TargetInput, type TargetKind, type ToolAnnotations, type ToolDef, type ToolSafety, assembleBundle, canaryMatch, canonicalStringify, classifyTool, connectTarget, decodeLitmusAttestation, encodeLitmusAttestation, fingerprintToolDefs, formatServerRef, gateDecision, gradeFromCategories, handleRunLitmus, hasHighSeverity, instructionMimicry, invisibleUnicode, litmusFields, litmusSchemaUID, liveFingerprint, markdownTricks, networkConfig, parseAuthFlags, parseServerRef, readAttestation, resolveTarget, rpcUrl, runLitmus, runLitmusInputShape, selectedNetwork, serverKey, stateChangingToolNames };