@crawlcheck/sdk 1.0.8 → 1.1.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/README.md +19 -0
- package/dist/index.d.ts +134 -10
- package/dist/index.js +160 -13
- package/dist/mcp.d.ts +50 -0
- package/dist/mcp.js +164 -0
- package/examples/mcp-preflight-demo.mjs +33 -0
- package/package.json +4 -2
- package/test/fixtures/guard-acceptance.json +4694 -0
- package/test/guard-acceptance.test.js +54 -0
- package/test/mcp.test.js +81 -0
package/README.md
CHANGED
|
@@ -50,6 +50,25 @@ v.decisions.transact; // allow | warn | require_confirmation | block | unsuppor
|
|
|
50
50
|
|
|
51
51
|
`stapleCheck(record, { host, publishedKids })` does the offline check on its own. Spec: https://crawlcheck.io/spec/staple
|
|
52
52
|
|
|
53
|
+
## Preflight inside your MCP client
|
|
54
|
+
|
|
55
|
+
Wrap the official MCP client so `connect` and `callTool` are decided before anything is sent ([proposal](https://crawlcheck.io/spec/mcp-preflight)):
|
|
56
|
+
|
|
57
|
+
```js
|
|
58
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
59
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
60
|
+
import { CrawlCheck, withPreflight, McpPreflightRefused } from "@crawlcheck/sdk";
|
|
61
|
+
|
|
62
|
+
const client = withPreflight(new Client({ name: "my-agent", version: "1.0.0" }), new CrawlCheck(), {
|
|
63
|
+
confirm: async (receipt) => askTheUser(receipt), // require_confirmation, and destructive tools with confirmDestructive
|
|
64
|
+
lock: approvedLock, // optional: refuse a server whose tools changed, and any tool outside the lock
|
|
65
|
+
});
|
|
66
|
+
try { await client.connect(new StreamableHTTPClientTransport(new URL(url))); }
|
|
67
|
+
catch (e) { if (e instanceof McpPreflightRefused) console.log(e.stage, e.why, e.receipt.decision); else throw e; }
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`block`, `unsupported`, an unverifiable receipt, or `require_confirmation` with no yes refuse the connect before `initialize`. Other client stacks use the bare hooks: `mcpPreflight(cc, opts)` returns `beforeConnect`, `afterConnect` and `beforeToolCall`. A runnable demo is in `examples/mcp-preflight-demo.mjs`.
|
|
71
|
+
|
|
53
72
|
## Do not trust the API: check the evidence yourself
|
|
54
73
|
|
|
55
74
|
```js
|
package/dist/index.d.ts
CHANGED
|
@@ -195,7 +195,8 @@ export interface PreflightPolicy {
|
|
|
195
195
|
export interface PreflightOptions {
|
|
196
196
|
agent?: string;
|
|
197
197
|
template?: string;
|
|
198
|
-
policy?: PreflightPolicy;
|
|
198
|
+
policy?: PreflightPolicy; /** 8-128 characters of A-Z a-z 0-9 . _ : - signed into the receipt (replay binding). guard() sends a fresh one when you give none. */
|
|
199
|
+
nonce?: string;
|
|
199
200
|
}
|
|
200
201
|
/** A signed crawlcheck-decision receipt. Verify it with verifyDocument (or let guard() do it). */
|
|
201
202
|
export interface DecisionReceipt {
|
|
@@ -220,8 +221,60 @@ export interface GuardOptions extends PreflightOptions {
|
|
|
220
221
|
allowWarn?: boolean;
|
|
221
222
|
/** Called on "require_confirmation"; proceed only if it resolves true. Without it, require_confirmation does not proceed. */
|
|
222
223
|
confirm?: (receipt: DecisionReceipt) => boolean | Promise<boolean>;
|
|
223
|
-
/**
|
|
224
|
+
/** Read the published key directory (default true). With false, pass publishedKids: a guard never accepts a receipt without checking who signed it. */
|
|
224
225
|
onlineKeys?: boolean;
|
|
226
|
+
/** Pinned CrawlCheck key ids, for offline use with onlineKeys: false. */
|
|
227
|
+
publishedKids?: string[];
|
|
228
|
+
/** Testing clock (ms since epoch). Default Date.now(). */
|
|
229
|
+
now?: number;
|
|
230
|
+
}
|
|
231
|
+
/** The longest a receipt may stay valid, per action (seconds). A receipt that claims longer is refused. Published at https://crawlcheck.io/spec/guard */
|
|
232
|
+
export declare const ACTION_MAX_LIFETIME_S: Record<string, number>;
|
|
233
|
+
/** The most clock skew a receipt may claim (seconds). */
|
|
234
|
+
export declare const MAX_CLOCK_SKEW_S = 300;
|
|
235
|
+
export interface DecisionWant {
|
|
236
|
+
domain: string;
|
|
237
|
+
action: Action | string;
|
|
238
|
+
nonce?: string | null;
|
|
239
|
+
now?: number;
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Is this receipt about the operation you are about to perform? Checks the scope only (kind, domain, action, nonce,
|
|
243
|
+
* time window, per-action lifetime, known decision, resolve binding); the signature and issuer are checked by
|
|
244
|
+
* verifyDocument. guard() runs both. https://crawlcheck.io/spec/guard
|
|
245
|
+
*/
|
|
246
|
+
export declare function checkDecision(receipt: unknown, want: DecisionWant): {
|
|
247
|
+
ok: boolean;
|
|
248
|
+
why: string[];
|
|
249
|
+
};
|
|
250
|
+
export interface GuardResult {
|
|
251
|
+
proceed: boolean;
|
|
252
|
+
receipt: DecisionReceipt | null;
|
|
253
|
+
verification: (DocumentVerification & Trust) | null;
|
|
254
|
+
header: Record<string, string> | null;
|
|
255
|
+
why: string[];
|
|
256
|
+
split_view?: LogFork[];
|
|
257
|
+
}
|
|
258
|
+
export interface GuardedFetchOptions extends GuardOptions {
|
|
259
|
+
action?: Action;
|
|
260
|
+
init?: RequestInit;
|
|
261
|
+
maxHops?: number;
|
|
262
|
+
fetch?: typeof fetch;
|
|
263
|
+
}
|
|
264
|
+
export interface GuardedHop {
|
|
265
|
+
url: string;
|
|
266
|
+
domain: string;
|
|
267
|
+
proceed: boolean;
|
|
268
|
+
decision: string | null;
|
|
269
|
+
why: string[];
|
|
270
|
+
status?: number;
|
|
271
|
+
}
|
|
272
|
+
export interface GuardedFetchResult {
|
|
273
|
+
proceed: boolean;
|
|
274
|
+
response: Response | null;
|
|
275
|
+
url: string;
|
|
276
|
+
hops: GuardedHop[];
|
|
277
|
+
why: string[];
|
|
225
278
|
}
|
|
226
279
|
export interface LogHead {
|
|
227
280
|
kind: "crawlcheck-log-sth";
|
|
@@ -356,6 +409,71 @@ export declare const stapleCheck: (record: string, opts: {
|
|
|
356
409
|
publishedKids: string[];
|
|
357
410
|
now?: number;
|
|
358
411
|
}) => Promise<StapleCheck>;
|
|
412
|
+
export interface McpPreflightOptions extends PreflightOptions {
|
|
413
|
+
/** Proceed on warn too (default false). */
|
|
414
|
+
allowWarn?: boolean;
|
|
415
|
+
/** Called on require_confirmation (and, with confirmDestructive, before a destructive tool call); the connect or call proceeds only if it resolves true. */
|
|
416
|
+
confirm?: (receipt: DecisionReceipt & {
|
|
417
|
+
tool?: string;
|
|
418
|
+
annotations?: unknown;
|
|
419
|
+
}) => boolean | Promise<boolean>;
|
|
420
|
+
/** An approved lockfile (mcpLock / lockFromTools): the tools the server lists after connecting must match it, and only tools in it may be called. */
|
|
421
|
+
lock?: McpLock;
|
|
422
|
+
/** Ask confirm() before a tool annotated destructive (destructiveHint true, or readOnlyHint false with openWorldHint true). */
|
|
423
|
+
confirmDestructive?: boolean;
|
|
424
|
+
/** Re-decide the host when the cached connect receipt is older than this (default: until its expires_at). */
|
|
425
|
+
maxAgeMs?: number;
|
|
426
|
+
/** A local (stdio) server has no host to resolve: "allow" (default) or "refuse". */
|
|
427
|
+
local?: "allow" | "refuse";
|
|
428
|
+
/** The server URL when the transport does not expose it. */
|
|
429
|
+
serverUrl?: string;
|
|
430
|
+
onlineKeys?: boolean;
|
|
431
|
+
}
|
|
432
|
+
export interface McpPreflightState {
|
|
433
|
+
url: string | null;
|
|
434
|
+
host: string | null;
|
|
435
|
+
receipt: DecisionReceipt | null;
|
|
436
|
+
verification: (DocumentVerification & Trust) | null;
|
|
437
|
+
decidedAt: number;
|
|
438
|
+
lockCheck: McpLockCheck | null;
|
|
439
|
+
approved: Set<string> | null;
|
|
440
|
+
}
|
|
441
|
+
export interface McpPreflightHooks {
|
|
442
|
+
state: McpPreflightState;
|
|
443
|
+
beforeConnect(url: string | null | undefined): Promise<{
|
|
444
|
+
local: boolean;
|
|
445
|
+
host?: string;
|
|
446
|
+
receipt: DecisionReceipt | null;
|
|
447
|
+
verification?: DocumentVerification & Trust;
|
|
448
|
+
}>;
|
|
449
|
+
afterConnect(tools: McpTool[]): Promise<McpLockCheck | null>;
|
|
450
|
+
beforeToolCall(name: string, annotations?: unknown): Promise<{
|
|
451
|
+
receipt: DecisionReceipt | null;
|
|
452
|
+
}>;
|
|
453
|
+
}
|
|
454
|
+
/** Thrown by the hooks when a connect or tools/call is refused; the request was never sent. */
|
|
455
|
+
export declare const McpPreflightRefused: new (stage: "connect" | "tools/call", why: string, detail?: {
|
|
456
|
+
receipt?: DecisionReceipt | null;
|
|
457
|
+
lock?: McpLockCheck | null;
|
|
458
|
+
verification?: unknown;
|
|
459
|
+
}) => Error & {
|
|
460
|
+
stage: "connect" | "tools/call";
|
|
461
|
+
why: string;
|
|
462
|
+
receipt: DecisionReceipt | null;
|
|
463
|
+
lock: McpLockCheck | null;
|
|
464
|
+
verification: unknown;
|
|
465
|
+
};
|
|
466
|
+
/** The two hooks (beforeConnect, beforeToolCall) plus afterConnect for lockfiles, for any MCP client stack. */
|
|
467
|
+
export declare const mcpPreflight: (cc: CrawlCheck, opts?: McpPreflightOptions) => McpPreflightHooks;
|
|
468
|
+
/** Wrap an MCP client (the official @modelcontextprotocol/sdk Client, or anything with connect(transport) and callTool(params)) so both run the preflight first. Returns the same client with client.preflight set. */
|
|
469
|
+
export declare const withPreflight: <C extends {
|
|
470
|
+
connect: (...a: any[]) => Promise<any>;
|
|
471
|
+
callTool: (...a: any[]) => Promise<any>;
|
|
472
|
+
}>(client: C, cc: CrawlCheck, opts?: McpPreflightOptions) => C & {
|
|
473
|
+
preflight: McpPreflightHooks;
|
|
474
|
+
};
|
|
475
|
+
/** The server URL a transport will talk to, if it exposes one (Streamable HTTP and SSE transports do). */
|
|
476
|
+
export declare const transportUrl: (transport: unknown) => string | null;
|
|
359
477
|
export interface ClientOptions {
|
|
360
478
|
/** Licence key (cc_ + 32 hex). Without one, licence-gated fields come back withheld, exactly as on the website. */
|
|
361
479
|
key?: string;
|
|
@@ -434,14 +552,20 @@ export declare class CrawlCheck {
|
|
|
434
552
|
preflight(domain: string, action?: Action, opts?: PreflightOptions): Promise<DecisionReceipt>;
|
|
435
553
|
/** Verify a signed document here. With onlineKeys (default), its signing key must also be in the published directory. */
|
|
436
554
|
verifyDocument(doc: unknown, onlineKeys?: boolean): Promise<DocumentVerification & Trust>;
|
|
437
|
-
/**
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
555
|
+
/**
|
|
556
|
+
* Preflight, verify the receipt, check it is for THIS operation, and decide. Proceeds on allow; warn only with allowWarn;
|
|
557
|
+
* require_confirmation only if confirm() says yes; block, unsupported and every failure never (fail closed: an error,
|
|
558
|
+
* an unreachable CrawlCheck, a malformed, foreign, expired, replayed or out-of-scope receipt all return proceed false
|
|
559
|
+
* with why[]). Passes the acceptance suite at https://crawlcheck.io/spec/guard
|
|
560
|
+
*/
|
|
561
|
+
guard(domain: string, action?: Action, opts?: GuardOptions): Promise<GuardResult>;
|
|
562
|
+
/**
|
|
563
|
+
* fetch() with the check where the action runs: guard() before the first request, and again before every redirect hop
|
|
564
|
+
* to a new domain. The site is contacted only after its domain proceeds; a refused hop is never requested. Redirects
|
|
565
|
+
* are followed by hand (GET/HEAD only), to http(s) only, at most maxHops (10). The receipt header is sent only to the
|
|
566
|
+
* domain it names. In a browser a cross-origin redirect hides its target, so it is refused rather than followed blind.
|
|
567
|
+
*/
|
|
568
|
+
guardedFetch(url: string, opts?: GuardedFetchOptions): Promise<GuardedFetchResult>;
|
|
445
569
|
/** Look a domain up without sending it: only the first prefixLen hex characters of sha256(domain) leave this process. found false with ready true means no public measurement. */
|
|
446
570
|
/** Make a signed lockfile for an MCP server (CrawlCheck reads tools/list; no tool is called). Save it; check with checkLock before every connect. */
|
|
447
571
|
mcpLock(url: string): Promise<McpLock & JsonObject>;
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as V from "./verify.js";
|
|
2
2
|
import * as L from "./log.js";
|
|
3
3
|
import * as S from "./staple.js";
|
|
4
|
+
import * as M from "./mcp.js";
|
|
4
5
|
function withTrust(v, kid, published) {
|
|
5
6
|
let checks = v.checks;
|
|
6
7
|
let issuer = null;
|
|
@@ -37,6 +38,7 @@ export function normalizeDomain(v) {
|
|
|
37
38
|
}
|
|
38
39
|
/** { "CrawlCheck-Receipt": "v=1; sha256=..." } for a decision receipt. Send it only to the domain the receipt names, before expires_at. */
|
|
39
40
|
export function receiptHeader(receipt) { return { [RECEIPT_HEADER]: "v=1; sha256=" + receipt.sha256 }; }
|
|
41
|
+
function randomNonce() { const b = new Uint8Array(16); globalThis.crypto.getRandomValues(b); return Array.from(b, (x) => x.toString(16).padStart(2, "0")).join(""); }
|
|
40
42
|
async function sha256Hex(s) {
|
|
41
43
|
const b = await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(s));
|
|
42
44
|
return Array.from(new Uint8Array(b), (x) => x.toString(16).padStart(2, "0")).join("");
|
|
@@ -63,6 +65,50 @@ export async function checkLock(lock, tools) {
|
|
|
63
65
|
const same = lock?.tools_sha256 === now.tools_sha256 && !added.length && !removed.length && !modified.length;
|
|
64
66
|
return { verdict: same ? "unchanged" : "changed", decision: same ? "connect" : "block", approved_tools_sha256: lock?.tools_sha256 ?? "", current_tools_sha256: now.tools_sha256, added, removed, modified };
|
|
65
67
|
}
|
|
68
|
+
/** The longest a receipt may stay valid, per action (seconds). A receipt that claims longer is refused. Published at https://crawlcheck.io/spec/guard */
|
|
69
|
+
export const ACTION_MAX_LIFETIME_S = { read: 86400, cite: 86400, connect: 3600, transact: 300, administer: 300 };
|
|
70
|
+
/** The most clock skew a receipt may claim (seconds). */
|
|
71
|
+
export const MAX_CLOCK_SKEW_S = 300;
|
|
72
|
+
const DECISIONS = ["allow", "warn", "require_confirmation", "block", "unsupported"];
|
|
73
|
+
/**
|
|
74
|
+
* Is this receipt about the operation you are about to perform? Checks the scope only (kind, domain, action, nonce,
|
|
75
|
+
* time window, per-action lifetime, known decision, resolve binding); the signature and issuer are checked by
|
|
76
|
+
* verifyDocument. guard() runs both. https://crawlcheck.io/spec/guard
|
|
77
|
+
*/
|
|
78
|
+
export function checkDecision(receipt, want) {
|
|
79
|
+
const why = [], r = receipt;
|
|
80
|
+
if (!r || typeof r !== "object" || Array.isArray(r))
|
|
81
|
+
return { ok: false, why: ["no receipt: CrawlCheck sent " + (r === null || r === undefined ? "nothing" : typeof r)] };
|
|
82
|
+
if (r.kind !== "crawlcheck-decision" || r.v !== 1)
|
|
83
|
+
why.push("not a v1 decision receipt (kind " + r.kind + ", v " + r.v + ")");
|
|
84
|
+
const wd = normalizeDomain(want.domain);
|
|
85
|
+
if (!wd || normalizeDomain(r.domain) !== wd)
|
|
86
|
+
why.push("the receipt is for " + r.domain + ", not " + want.domain);
|
|
87
|
+
if (String(r.action) !== String(want.action))
|
|
88
|
+
why.push("the receipt is for action " + r.action + ", not " + want.action);
|
|
89
|
+
if (want.nonce && r.nonce !== want.nonce)
|
|
90
|
+
why.push(r.nonce ? "the receipt carries another request's nonce (replayed)" : "the receipt does not carry the nonce this request sent");
|
|
91
|
+
const now = want.now ?? Date.now(), skew = Math.min(Math.max(Number(r.clock_skew_s) || 0, 0), MAX_CLOCK_SKEW_S) * 1000;
|
|
92
|
+
const at = Date.parse(r.decided_at), exp = Date.parse(r.expires_at);
|
|
93
|
+
if (!isFinite(at) || !isFinite(exp))
|
|
94
|
+
why.push("decided_at or expires_at missing");
|
|
95
|
+
else {
|
|
96
|
+
if (now > exp + skew)
|
|
97
|
+
why.push("expired at " + r.expires_at);
|
|
98
|
+
if (at > now + skew)
|
|
99
|
+
why.push("decided in the future (" + r.decided_at + "): check the clock");
|
|
100
|
+
const max = ACTION_MAX_LIFETIME_S[String(want.action)];
|
|
101
|
+
if (max === undefined)
|
|
102
|
+
why.push("unknown action " + want.action);
|
|
103
|
+
else if (exp - at > max * 1000 + 1000)
|
|
104
|
+
why.push("valid for " + Math.round((exp - at) / 1000) + " s; a " + want.action + " receipt may last at most " + max + " s");
|
|
105
|
+
}
|
|
106
|
+
if (DECISIONS.indexOf(r.decision) < 0)
|
|
107
|
+
why.push("unknown decision " + r.decision);
|
|
108
|
+
if (!r.resolve || !/^[0-9a-f]{64}$/.test(String(r.resolve.sha256 || "")))
|
|
109
|
+
why.push("the receipt names no resolve answer");
|
|
110
|
+
return { ok: why.length === 0, why };
|
|
111
|
+
}
|
|
66
112
|
export const LOG_ID = L.LOG_ID;
|
|
67
113
|
/** A new, empty transparency-log store (plain JSON: persist it between runs). */
|
|
68
114
|
export const newLogStore = L.newLogStore;
|
|
@@ -83,6 +129,14 @@ export const parseStaple = S.parseStaple;
|
|
|
83
129
|
export const stapleFromResponse = S.stapleFromResponse;
|
|
84
130
|
/** Verify a staple offline: published key, signature, the record names opts.host (or a parent), fresh_until in the future. Expired or foreign staples are never valid. */
|
|
85
131
|
export const stapleCheck = S.stapleCheck;
|
|
132
|
+
/** Thrown by the hooks when a connect or tools/call is refused; the request was never sent. */
|
|
133
|
+
export const McpPreflightRefused = M.McpPreflightRefused;
|
|
134
|
+
/** The two hooks (beforeConnect, beforeToolCall) plus afterConnect for lockfiles, for any MCP client stack. */
|
|
135
|
+
export const mcpPreflight = M.mcpPreflight;
|
|
136
|
+
/** Wrap an MCP client (the official @modelcontextprotocol/sdk Client, or anything with connect(transport) and callTool(params)) so both run the preflight first. Returns the same client with client.preflight set. */
|
|
137
|
+
export const withPreflight = M.withPreflight;
|
|
138
|
+
/** The server URL a transport will talk to, if it exposes one (Streamable HTTP and SSE transports do). */
|
|
139
|
+
export const transportUrl = M.transportUrl;
|
|
86
140
|
export class CrawlCheckError extends Error {
|
|
87
141
|
status;
|
|
88
142
|
body;
|
|
@@ -190,29 +244,122 @@ export class CrawlCheck {
|
|
|
190
244
|
b.template = opts.template;
|
|
191
245
|
if (opts.policy)
|
|
192
246
|
b.policy = opts.policy;
|
|
247
|
+
if (opts.nonce)
|
|
248
|
+
b.nonce = opts.nonce;
|
|
193
249
|
return this.post("/api/v1/preflight", b);
|
|
194
250
|
}
|
|
195
251
|
/** Verify a signed document here. With onlineKeys (default), its signing key must also be in the published directory. */
|
|
196
252
|
async verifyDocument(doc, onlineKeys = true) {
|
|
197
253
|
return withTrust(await V.verifyDataDoc(doc), doc?.signature?.kid, onlineKeys ? await this.publishedKeyIds() : null);
|
|
198
254
|
}
|
|
199
|
-
/**
|
|
255
|
+
/**
|
|
256
|
+
* Preflight, verify the receipt, check it is for THIS operation, and decide. Proceeds on allow; warn only with allowWarn;
|
|
257
|
+
* require_confirmation only if confirm() says yes; block, unsupported and every failure never (fail closed: an error,
|
|
258
|
+
* an unreachable CrawlCheck, a malformed, foreign, expired, replayed or out-of-scope receipt all return proceed false
|
|
259
|
+
* with why[]). Passes the acceptance suite at https://crawlcheck.io/spec/guard
|
|
260
|
+
*/
|
|
200
261
|
async guard(domain, action = "read", opts = {}) {
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
262
|
+
const nonce = opts.nonce || randomNonce();
|
|
263
|
+
const no = (why, receipt = null, verification = null) => ({ proceed: false, receipt, verification, header: null, why });
|
|
264
|
+
let receipt;
|
|
265
|
+
try {
|
|
266
|
+
receipt = await this.preflight(domain, action, Object.assign({}, opts, { nonce }));
|
|
267
|
+
}
|
|
268
|
+
catch (e) {
|
|
269
|
+
return no(["CrawlCheck did not give a decision: " + (e?.message || e)]);
|
|
205
270
|
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
if (!
|
|
210
|
-
return
|
|
271
|
+
if (this.log.forks.length)
|
|
272
|
+
return Object.assign(no(["the transparency log has shown this client two histories (split view)"], receipt), { split_view: this.log.forks.slice() });
|
|
273
|
+
const scope = checkDecision(receipt, { domain, action, nonce, now: opts.now });
|
|
274
|
+
if (!receipt || typeof receipt !== "object")
|
|
275
|
+
return no(scope.why);
|
|
276
|
+
const online = opts.onlineKeys ?? true;
|
|
277
|
+
if (!online && !(Array.isArray(opts.publishedKids) && opts.publishedKids.length))
|
|
278
|
+
return no(["onlineKeys is false and no publishedKids were pinned: the signer cannot be checked"], receipt);
|
|
279
|
+
let verification;
|
|
280
|
+
try {
|
|
281
|
+
verification = withTrust(await V.verifyDataDoc(receipt), receipt?.signature?.kid, online ? await this.publishedKeyIds() : opts.publishedKids);
|
|
282
|
+
}
|
|
283
|
+
catch (e) {
|
|
284
|
+
return no(["the receipt could not be verified: " + (e?.message || e)].concat(scope.why), receipt);
|
|
285
|
+
}
|
|
286
|
+
const why = scope.why.slice();
|
|
287
|
+
if (!verification.integrity_valid)
|
|
288
|
+
why.push("integrity failed: " + verification.checks.filter((c) => c.ok === false).map((c) => c.id).join(", "));
|
|
289
|
+
if (verification.issuer_trusted !== true)
|
|
290
|
+
why.push("signed by a key that is not in CrawlCheck's published key directory");
|
|
291
|
+
if (why.length)
|
|
292
|
+
return no(why, receipt, verification);
|
|
211
293
|
const d = receipt.decision;
|
|
212
294
|
let proceed = d === "allow" || (d === "warn" && !!opts.allowWarn);
|
|
213
|
-
if (d === "require_confirmation"
|
|
214
|
-
|
|
215
|
-
|
|
295
|
+
if (d === "require_confirmation") {
|
|
296
|
+
if (opts.confirm) {
|
|
297
|
+
try {
|
|
298
|
+
proceed = (await opts.confirm(receipt)) === true;
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
proceed = false;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if (!proceed)
|
|
306
|
+
why.push(d === "warn" ? "decision is warn and allowWarn is not set" : d === "require_confirmation" ? (opts.confirm ? "a human did not confirm" : "decision is require_confirmation and no confirm() was given") : "decision is " + d);
|
|
307
|
+
return { proceed, receipt, verification, header: proceed ? receiptHeader(receipt) : null, why };
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* fetch() with the check where the action runs: guard() before the first request, and again before every redirect hop
|
|
311
|
+
* to a new domain. The site is contacted only after its domain proceeds; a refused hop is never requested. Redirects
|
|
312
|
+
* are followed by hand (GET/HEAD only), to http(s) only, at most maxHops (10). The receipt header is sent only to the
|
|
313
|
+
* domain it names. In a browser a cross-origin redirect hides its target, so it is refused rather than followed blind.
|
|
314
|
+
*/
|
|
315
|
+
async guardedFetch(url, opts = {}) {
|
|
316
|
+
const action = opts.action ?? "read", max = opts.maxHops ?? 10, f = opts.fetch ? opts.fetch.bind(globalThis) : this.f;
|
|
317
|
+
const init = Object.assign({}, opts.init || {}), method = String(init.method || "GET").toUpperCase();
|
|
318
|
+
const hops = [], seen = new Map();
|
|
319
|
+
let cur = url;
|
|
320
|
+
for (let n = 0; n <= max; n++) {
|
|
321
|
+
let u;
|
|
322
|
+
try {
|
|
323
|
+
u = new URL(cur);
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
return { proceed: false, response: null, url: cur, hops, why: ["not a URL: " + cur] };
|
|
327
|
+
}
|
|
328
|
+
if (u.protocol !== "https:" && u.protocol !== "http:")
|
|
329
|
+
return { proceed: false, response: null, url: cur, hops, why: ["refused a " + u.protocol + " URL"] };
|
|
330
|
+
const dom = normalizeDomain(u.hostname);
|
|
331
|
+
if (!dom)
|
|
332
|
+
return { proceed: false, response: null, url: cur, hops, why: ["not a public domain name: " + u.hostname] };
|
|
333
|
+
let g = seen.get(dom);
|
|
334
|
+
if (!g) {
|
|
335
|
+
g = await this.guard(dom, action, opts);
|
|
336
|
+
seen.set(dom, g);
|
|
337
|
+
}
|
|
338
|
+
const hop = { url: cur, domain: dom, proceed: g.proceed, decision: g.receipt ? String(g.receipt.decision) : null, why: g.why };
|
|
339
|
+
hops.push(hop);
|
|
340
|
+
if (!g.proceed)
|
|
341
|
+
return { proceed: false, response: null, url: cur, hops, why: [dom + ": " + (g.why.join("; ") || "refused")] };
|
|
342
|
+
const h = new Headers(init.headers || {});
|
|
343
|
+
if (g.header)
|
|
344
|
+
for (const [k, v] of Object.entries(g.header))
|
|
345
|
+
h.set(k, v);
|
|
346
|
+
const res = await f(cur, Object.assign({}, init, { headers: h, redirect: "manual" }));
|
|
347
|
+
hop.status = res.status;
|
|
348
|
+
if (res.type === "opaqueredirect")
|
|
349
|
+
return { proceed: false, response: null, url: cur, hops, why: ["the runtime hid the redirect target, so it cannot be checked"] };
|
|
350
|
+
const loc = res.headers.get("location");
|
|
351
|
+
if (![301, 302, 303, 307, 308].includes(res.status) || !loc)
|
|
352
|
+
return { proceed: true, response: res, url: cur, hops, why: [] };
|
|
353
|
+
if (method !== "GET" && method !== "HEAD")
|
|
354
|
+
return { proceed: true, response: res, url: cur, hops, why: ["redirect not followed for " + method] };
|
|
355
|
+
try {
|
|
356
|
+
cur = new URL(loc, cur).toString();
|
|
357
|
+
}
|
|
358
|
+
catch {
|
|
359
|
+
return { proceed: false, response: null, url: cur, hops, why: ["unreadable redirect target " + loc] };
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return { proceed: false, response: null, url: cur, hops, why: ["more than " + max + " redirects"] };
|
|
216
363
|
}
|
|
217
364
|
/** Look a domain up without sending it: only the first prefixLen hex characters of sha256(domain) leave this process. found false with ready true means no public measurement. */
|
|
218
365
|
/** Make a signed lockfile for an MCP server (CrawlCheck reads tools/list; no tool is called). Save it; check with checkLock before every connect. */
|
package/dist/mcp.d.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** The server URL a transport will talk to, if it can be read (Streamable HTTP and SSE transports keep it as _url or url). */
|
|
2
|
+
export function transportUrl(transport: any): string | null;
|
|
3
|
+
/**
|
|
4
|
+
* The two hooks. cc is a CrawlCheck client (from this SDK). opts: template (default safe_mcp_tool), policy, agent,
|
|
5
|
+
* confirm(receipt) for require_confirmation, allowWarn, lock (an approved McpLock), confirmDestructive, maxAgeMs for a
|
|
6
|
+
* cached connect receipt (default: until the receipt's expires_at).
|
|
7
|
+
*/
|
|
8
|
+
export function mcpPreflight(cc: any, opts?: {}): {
|
|
9
|
+
state: {
|
|
10
|
+
url: null;
|
|
11
|
+
host: null;
|
|
12
|
+
receipt: null;
|
|
13
|
+
verification: null;
|
|
14
|
+
decidedAt: number;
|
|
15
|
+
lockCheck: null;
|
|
16
|
+
approved: null;
|
|
17
|
+
};
|
|
18
|
+
/** Before initialize. url: the server URL (a stdio/local server has none and is not decided; pass opts.local = "allow" | "refuse", default allow). */
|
|
19
|
+
beforeConnect(url: any): Promise<{
|
|
20
|
+
local: boolean;
|
|
21
|
+
receipt: null;
|
|
22
|
+
host?: undefined;
|
|
23
|
+
verification?: undefined;
|
|
24
|
+
} | {
|
|
25
|
+
local: boolean;
|
|
26
|
+
host: string;
|
|
27
|
+
receipt: any;
|
|
28
|
+
verification: any;
|
|
29
|
+
}>;
|
|
30
|
+
/** After initialize, with the tools the server lists. Refuses (and the wrapper closes the client) when they differ from the approved lockfile. */
|
|
31
|
+
afterConnect(tools: any): Promise<import("./index.js").McpLockCheck | null>;
|
|
32
|
+
/** Before tools/call. */
|
|
33
|
+
beforeToolCall(name: any, annotations: any): Promise<{
|
|
34
|
+
receipt: null;
|
|
35
|
+
}>;
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Wrap an MCP client in place: client.connect and client.callTool run the hooks first. Returns the same client, with
|
|
39
|
+
* client.preflight (the hooks and their state). Works on @modelcontextprotocol/sdk's Client and on anything with the same
|
|
40
|
+
* two methods; listTools() is used for the lockfile check when present.
|
|
41
|
+
*/
|
|
42
|
+
export function withPreflight(client: any, cc: any, opts?: {}): any;
|
|
43
|
+
export class McpPreflightRefused extends Error {
|
|
44
|
+
constructor(stage: any, why: any, detail: any);
|
|
45
|
+
stage: any;
|
|
46
|
+
why: any;
|
|
47
|
+
receipt: any;
|
|
48
|
+
lock: any;
|
|
49
|
+
verification: any;
|
|
50
|
+
}
|
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// Preflight as an MCP client extension (https://crawlcheck.io/spec/mcp-preflight). Reference middleware: the check runs
|
|
2
|
+
// in the protocol path, before `initialize` reaches a server and before every `tools/call`, instead of being a call the
|
|
3
|
+
// agent remembers to make. It wraps any MCP client object that has connect(transport) and callTool(params) (the official
|
|
4
|
+
// @modelcontextprotocol/sdk Client does), or is used as two bare hooks for other client stacks.
|
|
5
|
+
//
|
|
6
|
+
// connect: the server's host is resolved and the "connect" action decided (template safe_mcp_tool). block, unsupported
|
|
7
|
+
// and an unverifiable receipt refuse the connect before a byte is sent; require_confirmation asks opts.confirm.
|
|
8
|
+
// With opts.lock, the tools the server lists after connecting must match the approved lockfile, or the client
|
|
9
|
+
// is closed again.
|
|
10
|
+
// tools/call: the connect receipt must still be unexpired (else the host is decided again); a tool outside the approved
|
|
11
|
+
// lockfile is refused; a tool annotated destructive (destructiveHint true, or readOnlyHint false with
|
|
12
|
+
// openWorldHint true) asks opts.confirm when opts.confirmDestructive is set.
|
|
13
|
+
// Nothing here calls a tool, reads arguments or alters the request; a refused call throws McpPreflightRefused and the
|
|
14
|
+
// request is never sent.
|
|
15
|
+
import { normalizeDomain } from "./index.js";
|
|
16
|
+
import { checkLock } from "./index.js";
|
|
17
|
+
export class McpPreflightRefused extends Error {
|
|
18
|
+
constructor(stage, why, detail) { super("MCP preflight refused " + stage + ": " + why); this.name = "McpPreflightRefused"; this.stage = stage; this.why = why; this.receipt = detail && detail.receipt || null; this.lock = detail && detail.lock || null; this.verification = detail && detail.verification || null; }
|
|
19
|
+
}
|
|
20
|
+
/** The server URL a transport will talk to, if it can be read (Streamable HTTP and SSE transports keep it as _url or url). */
|
|
21
|
+
export function transportUrl(transport) {
|
|
22
|
+
if (!transport)
|
|
23
|
+
return null;
|
|
24
|
+
for (const k of ["_url", "url", "endpoint", "_endpoint"]) {
|
|
25
|
+
const v = transport[k];
|
|
26
|
+
if (v instanceof URL)
|
|
27
|
+
return v.toString();
|
|
28
|
+
if (typeof v === "string" && /^https?:\/\//i.test(v))
|
|
29
|
+
return v;
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
function hostOf(url) { try {
|
|
34
|
+
return normalizeDomain(new URL(url).hostname);
|
|
35
|
+
}
|
|
36
|
+
catch (e) {
|
|
37
|
+
return null;
|
|
38
|
+
} }
|
|
39
|
+
function destructive(ann) { if (!ann || typeof ann !== "object")
|
|
40
|
+
return false; if (ann.destructiveHint === true)
|
|
41
|
+
return true; return ann.readOnlyHint === false && ann.openWorldHint === true; }
|
|
42
|
+
/**
|
|
43
|
+
* The two hooks. cc is a CrawlCheck client (from this SDK). opts: template (default safe_mcp_tool), policy, agent,
|
|
44
|
+
* confirm(receipt) for require_confirmation, allowWarn, lock (an approved McpLock), confirmDestructive, maxAgeMs for a
|
|
45
|
+
* cached connect receipt (default: until the receipt's expires_at).
|
|
46
|
+
*/
|
|
47
|
+
export function mcpPreflight(cc, opts = {}) {
|
|
48
|
+
const template = opts.template || "safe_mcp_tool";
|
|
49
|
+
const state = { url: null, host: null, receipt: null, verification: null, decidedAt: 0, lockCheck: null, approved: null };
|
|
50
|
+
const fresh = function () {
|
|
51
|
+
if (!state.receipt)
|
|
52
|
+
return false;
|
|
53
|
+
const exp = Date.parse(state.receipt.expires_at || "");
|
|
54
|
+
if (isFinite(exp) && Date.now() > exp)
|
|
55
|
+
return false;
|
|
56
|
+
if (opts.maxAgeMs && Date.now() - state.decidedAt > opts.maxAgeMs)
|
|
57
|
+
return false;
|
|
58
|
+
return true;
|
|
59
|
+
};
|
|
60
|
+
const decide = async function (host) {
|
|
61
|
+
const g = await cc.guard(host, "connect", { template, policy: opts.policy, agent: opts.agent, confirm: opts.confirm, allowWarn: !!opts.allowWarn, onlineKeys: opts.onlineKeys });
|
|
62
|
+
state.receipt = g.receipt;
|
|
63
|
+
state.verification = g.verification;
|
|
64
|
+
state.decidedAt = Date.now();
|
|
65
|
+
if (!g.proceed) {
|
|
66
|
+
const d = g.receipt && g.receipt.decision;
|
|
67
|
+
const decisionOnly = (g.why || []).every(function (w) { return /^decision is|a human did not confirm|no confirm\(\) was given|allowWarn is not set/.test(w); });
|
|
68
|
+
const why = g.split_view ? "the transparency log showed two histories; no answer is trusted" : !g.verification ? ((g.why || []).join("; ") || "no decision") : !g.verification.verified ? "the decision receipt did not verify" : g.verification.accepted === false ? "the receipt was signed with a key CrawlCheck does not publish" : !decisionOnly ? g.why.join("; ") : !g.verification.verified ? "the decision receipt did not verify" : g.verification.accepted === false ? "the receipt was signed with a key CrawlCheck does not publish" : d === "require_confirmation" ? "a human must confirm and none did" : d === "warn" ? "warn without allowWarn" : d === "unsupported" ? "nothing vouches for a connection to " + host + " (" + ((g.receipt.steps || [])[0] || {}).why + ")" : "decision " + d + (g.receipt.steps && g.receipt.steps[0] ? " (" + g.receipt.steps[0].why + ")" : "");
|
|
69
|
+
throw new McpPreflightRefused("connect", why, { receipt: g.receipt, verification: g.verification });
|
|
70
|
+
}
|
|
71
|
+
return g;
|
|
72
|
+
};
|
|
73
|
+
return {
|
|
74
|
+
state,
|
|
75
|
+
/** Before initialize. url: the server URL (a stdio/local server has none and is not decided; pass opts.local = "allow" | "refuse", default allow). */
|
|
76
|
+
async beforeConnect(url) {
|
|
77
|
+
if (!url) {
|
|
78
|
+
if (opts.local === "refuse")
|
|
79
|
+
throw new McpPreflightRefused("connect", "a local (stdio) server cannot be resolved and opts.local is refuse");
|
|
80
|
+
state.url = null;
|
|
81
|
+
state.host = null;
|
|
82
|
+
return { local: true, receipt: null };
|
|
83
|
+
}
|
|
84
|
+
const host = hostOf(url);
|
|
85
|
+
if (!host)
|
|
86
|
+
throw new McpPreflightRefused("connect", "not a server URL: " + url);
|
|
87
|
+
state.url = url;
|
|
88
|
+
state.host = host;
|
|
89
|
+
const g = await decide(host);
|
|
90
|
+
return { local: false, host, receipt: g.receipt, verification: g.verification };
|
|
91
|
+
},
|
|
92
|
+
/** After initialize, with the tools the server lists. Refuses (and the wrapper closes the client) when they differ from the approved lockfile. */
|
|
93
|
+
async afterConnect(tools) {
|
|
94
|
+
if (!opts.lock) {
|
|
95
|
+
state.approved = null;
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
const lc = await checkLock(opts.lock, tools || []);
|
|
99
|
+
state.lockCheck = lc;
|
|
100
|
+
state.approved = new Set((opts.lock.tools || []).map(function (t) { return t.name; }));
|
|
101
|
+
if (lc.decision !== "connect")
|
|
102
|
+
throw new McpPreflightRefused("connect", "the server's tools differ from the approved lockfile (added " + lc.added.length + ", removed " + lc.removed.length + ", modified " + lc.modified.length + ")", { lock: lc });
|
|
103
|
+
return lc;
|
|
104
|
+
},
|
|
105
|
+
/** Before tools/call. */
|
|
106
|
+
async beforeToolCall(name, annotations) {
|
|
107
|
+
if (state.host && !fresh())
|
|
108
|
+
await decide(state.host);
|
|
109
|
+
if (state.approved && !state.approved.has(name))
|
|
110
|
+
throw new McpPreflightRefused("tools/call", "tool " + name + " is not in the approved lockfile", { lock: state.lockCheck, receipt: state.receipt });
|
|
111
|
+
if (opts.confirmDestructive && destructive(annotations)) {
|
|
112
|
+
const ok = opts.confirm ? await opts.confirm(Object.assign({}, state.receipt || {}, { tool: name, annotations })) : false;
|
|
113
|
+
if (!ok)
|
|
114
|
+
throw new McpPreflightRefused("tools/call", "tool " + name + " is annotated destructive and no human confirmed", { receipt: state.receipt });
|
|
115
|
+
}
|
|
116
|
+
return { receipt: state.receipt };
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Wrap an MCP client in place: client.connect and client.callTool run the hooks first. Returns the same client, with
|
|
122
|
+
* client.preflight (the hooks and their state). Works on @modelcontextprotocol/sdk's Client and on anything with the same
|
|
123
|
+
* two methods; listTools() is used for the lockfile check when present.
|
|
124
|
+
*/
|
|
125
|
+
export function withPreflight(client, cc, opts = {}) {
|
|
126
|
+
const pf = mcpPreflight(cc, opts);
|
|
127
|
+
const connect = client.connect.bind(client), callTool = client.callTool.bind(client);
|
|
128
|
+
const tools = {};
|
|
129
|
+
client.connect = async function (transport, ...rest) {
|
|
130
|
+
await pf.beforeConnect(opts.serverUrl || transportUrl(transport));
|
|
131
|
+
const r = await connect(transport, ...rest);
|
|
132
|
+
if (opts.lock || opts.confirmDestructive) {
|
|
133
|
+
let list = [];
|
|
134
|
+
try {
|
|
135
|
+
const t = typeof client.listTools === "function" ? await client.listTools() : null;
|
|
136
|
+
list = (t && t.tools) || [];
|
|
137
|
+
}
|
|
138
|
+
catch (e) {
|
|
139
|
+
list = [];
|
|
140
|
+
}
|
|
141
|
+
list.forEach(function (t) { if (t && t.name)
|
|
142
|
+
tools[t.name] = t; });
|
|
143
|
+
try {
|
|
144
|
+
await pf.afterConnect(list);
|
|
145
|
+
}
|
|
146
|
+
catch (e) {
|
|
147
|
+
try {
|
|
148
|
+
if (typeof client.close === "function")
|
|
149
|
+
await client.close();
|
|
150
|
+
}
|
|
151
|
+
catch (e2) { }
|
|
152
|
+
throw e;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return r;
|
|
156
|
+
};
|
|
157
|
+
client.callTool = async function (params, ...rest) {
|
|
158
|
+
const name = params && params.name;
|
|
159
|
+
await pf.beforeToolCall(name, tools[name] && tools[name].annotations);
|
|
160
|
+
return callTool(params, ...rest);
|
|
161
|
+
};
|
|
162
|
+
client.preflight = pf;
|
|
163
|
+
return client;
|
|
164
|
+
}
|