@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.
- package/LICENSE +190 -0
- package/README.md +114 -0
- package/dist/chunk-2K6T4FZX.js +1458 -0
- package/dist/chunk-6QM4RK25.js +180 -0
- package/dist/chunk-MQC54LFV.js +218 -0
- package/dist/chunk-SAZKXB35.js +120 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +119 -0
- package/dist/docker/egress-sniff.Dockerfile +13 -0
- package/dist/docker/sink-entrypoint.sh +9 -0
- package/dist/docker/sinkhole.mjs +90 -0
- package/dist/index.d.ts +594 -0
- package/dist/index.js +130 -0
- package/dist/mcp.d.ts +16 -0
- package/dist/mcp.js +230 -0
- package/dist/src-XIEFSTXC.js +29 -0
- package/package.json +75 -0
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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 };
|