@crawlcheck/sdk 1.0.7 → 1.0.9
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 +65 -0
- package/dist/index.js +9 -0
- package/dist/mcp.d.ts +50 -0
- package/dist/mcp.js +163 -0
- package/dist/verify.d.ts +10 -0
- package/dist/verify.js +181 -3
- package/examples/mcp-preflight-demo.mjs +33 -0
- package/package.json +2 -1
- 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
|
@@ -356,6 +356,71 @@ export declare const stapleCheck: (record: string, opts: {
|
|
|
356
356
|
publishedKids: string[];
|
|
357
357
|
now?: number;
|
|
358
358
|
}) => Promise<StapleCheck>;
|
|
359
|
+
export interface McpPreflightOptions extends PreflightOptions {
|
|
360
|
+
/** Proceed on warn too (default false). */
|
|
361
|
+
allowWarn?: boolean;
|
|
362
|
+
/** Called on require_confirmation (and, with confirmDestructive, before a destructive tool call); the connect or call proceeds only if it resolves true. */
|
|
363
|
+
confirm?: (receipt: DecisionReceipt & {
|
|
364
|
+
tool?: string;
|
|
365
|
+
annotations?: unknown;
|
|
366
|
+
}) => boolean | Promise<boolean>;
|
|
367
|
+
/** An approved lockfile (mcpLock / lockFromTools): the tools the server lists after connecting must match it, and only tools in it may be called. */
|
|
368
|
+
lock?: McpLock;
|
|
369
|
+
/** Ask confirm() before a tool annotated destructive (destructiveHint true, or readOnlyHint false with openWorldHint true). */
|
|
370
|
+
confirmDestructive?: boolean;
|
|
371
|
+
/** Re-decide the host when the cached connect receipt is older than this (default: until its expires_at). */
|
|
372
|
+
maxAgeMs?: number;
|
|
373
|
+
/** A local (stdio) server has no host to resolve: "allow" (default) or "refuse". */
|
|
374
|
+
local?: "allow" | "refuse";
|
|
375
|
+
/** The server URL when the transport does not expose it. */
|
|
376
|
+
serverUrl?: string;
|
|
377
|
+
onlineKeys?: boolean;
|
|
378
|
+
}
|
|
379
|
+
export interface McpPreflightState {
|
|
380
|
+
url: string | null;
|
|
381
|
+
host: string | null;
|
|
382
|
+
receipt: DecisionReceipt | null;
|
|
383
|
+
verification: (DocumentVerification & Trust) | null;
|
|
384
|
+
decidedAt: number;
|
|
385
|
+
lockCheck: McpLockCheck | null;
|
|
386
|
+
approved: Set<string> | null;
|
|
387
|
+
}
|
|
388
|
+
export interface McpPreflightHooks {
|
|
389
|
+
state: McpPreflightState;
|
|
390
|
+
beforeConnect(url: string | null | undefined): Promise<{
|
|
391
|
+
local: boolean;
|
|
392
|
+
host?: string;
|
|
393
|
+
receipt: DecisionReceipt | null;
|
|
394
|
+
verification?: DocumentVerification & Trust;
|
|
395
|
+
}>;
|
|
396
|
+
afterConnect(tools: McpTool[]): Promise<McpLockCheck | null>;
|
|
397
|
+
beforeToolCall(name: string, annotations?: unknown): Promise<{
|
|
398
|
+
receipt: DecisionReceipt | null;
|
|
399
|
+
}>;
|
|
400
|
+
}
|
|
401
|
+
/** Thrown by the hooks when a connect or tools/call is refused; the request was never sent. */
|
|
402
|
+
export declare const McpPreflightRefused: new (stage: "connect" | "tools/call", why: string, detail?: {
|
|
403
|
+
receipt?: DecisionReceipt | null;
|
|
404
|
+
lock?: McpLockCheck | null;
|
|
405
|
+
verification?: unknown;
|
|
406
|
+
}) => Error & {
|
|
407
|
+
stage: "connect" | "tools/call";
|
|
408
|
+
why: string;
|
|
409
|
+
receipt: DecisionReceipt | null;
|
|
410
|
+
lock: McpLockCheck | null;
|
|
411
|
+
verification: unknown;
|
|
412
|
+
};
|
|
413
|
+
/** The two hooks (beforeConnect, beforeToolCall) plus afterConnect for lockfiles, for any MCP client stack. */
|
|
414
|
+
export declare const mcpPreflight: (cc: CrawlCheck, opts?: McpPreflightOptions) => McpPreflightHooks;
|
|
415
|
+
/** 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. */
|
|
416
|
+
export declare const withPreflight: <C extends {
|
|
417
|
+
connect: (...a: any[]) => Promise<any>;
|
|
418
|
+
callTool: (...a: any[]) => Promise<any>;
|
|
419
|
+
}>(client: C, cc: CrawlCheck, opts?: McpPreflightOptions) => C & {
|
|
420
|
+
preflight: McpPreflightHooks;
|
|
421
|
+
};
|
|
422
|
+
/** The server URL a transport will talk to, if it exposes one (Streamable HTTP and SSE transports do). */
|
|
423
|
+
export declare const transportUrl: (transport: unknown) => string | null;
|
|
359
424
|
export interface ClientOptions {
|
|
360
425
|
/** Licence key (cc_ + 32 hex). Without one, licence-gated fields come back withheld, exactly as on the website. */
|
|
361
426
|
key?: string;
|
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;
|
|
@@ -83,6 +84,14 @@ export const parseStaple = S.parseStaple;
|
|
|
83
84
|
export const stapleFromResponse = S.stapleFromResponse;
|
|
84
85
|
/** 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
86
|
export const stapleCheck = S.stapleCheck;
|
|
87
|
+
/** Thrown by the hooks when a connect or tools/call is refused; the request was never sent. */
|
|
88
|
+
export const McpPreflightRefused = M.McpPreflightRefused;
|
|
89
|
+
/** The two hooks (beforeConnect, beforeToolCall) plus afterConnect for lockfiles, for any MCP client stack. */
|
|
90
|
+
export const mcpPreflight = M.mcpPreflight;
|
|
91
|
+
/** 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. */
|
|
92
|
+
export const withPreflight = M.withPreflight;
|
|
93
|
+
/** The server URL a transport will talk to, if it exposes one (Streamable HTTP and SSE transports do). */
|
|
94
|
+
export const transportUrl = M.transportUrl;
|
|
86
95
|
export class CrawlCheckError extends Error {
|
|
87
96
|
status;
|
|
88
97
|
body;
|
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,163 @@
|
|
|
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 why = g.split_view ? "the transparency log showed two histories; no answer is trusted" : !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 + ")" : "");
|
|
68
|
+
throw new McpPreflightRefused("connect", why, { receipt: g.receipt, verification: g.verification });
|
|
69
|
+
}
|
|
70
|
+
return g;
|
|
71
|
+
};
|
|
72
|
+
return {
|
|
73
|
+
state,
|
|
74
|
+
/** Before initialize. url: the server URL (a stdio/local server has none and is not decided; pass opts.local = "allow" | "refuse", default allow). */
|
|
75
|
+
async beforeConnect(url) {
|
|
76
|
+
if (!url) {
|
|
77
|
+
if (opts.local === "refuse")
|
|
78
|
+
throw new McpPreflightRefused("connect", "a local (stdio) server cannot be resolved and opts.local is refuse");
|
|
79
|
+
state.url = null;
|
|
80
|
+
state.host = null;
|
|
81
|
+
return { local: true, receipt: null };
|
|
82
|
+
}
|
|
83
|
+
const host = hostOf(url);
|
|
84
|
+
if (!host)
|
|
85
|
+
throw new McpPreflightRefused("connect", "not a server URL: " + url);
|
|
86
|
+
state.url = url;
|
|
87
|
+
state.host = host;
|
|
88
|
+
const g = await decide(host);
|
|
89
|
+
return { local: false, host, receipt: g.receipt, verification: g.verification };
|
|
90
|
+
},
|
|
91
|
+
/** After initialize, with the tools the server lists. Refuses (and the wrapper closes the client) when they differ from the approved lockfile. */
|
|
92
|
+
async afterConnect(tools) {
|
|
93
|
+
if (!opts.lock) {
|
|
94
|
+
state.approved = null;
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
const lc = await checkLock(opts.lock, tools || []);
|
|
98
|
+
state.lockCheck = lc;
|
|
99
|
+
state.approved = new Set((opts.lock.tools || []).map(function (t) { return t.name; }));
|
|
100
|
+
if (lc.decision !== "connect")
|
|
101
|
+
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 });
|
|
102
|
+
return lc;
|
|
103
|
+
},
|
|
104
|
+
/** Before tools/call. */
|
|
105
|
+
async beforeToolCall(name, annotations) {
|
|
106
|
+
if (state.host && !fresh())
|
|
107
|
+
await decide(state.host);
|
|
108
|
+
if (state.approved && !state.approved.has(name))
|
|
109
|
+
throw new McpPreflightRefused("tools/call", "tool " + name + " is not in the approved lockfile", { lock: state.lockCheck, receipt: state.receipt });
|
|
110
|
+
if (opts.confirmDestructive && destructive(annotations)) {
|
|
111
|
+
const ok = opts.confirm ? await opts.confirm(Object.assign({}, state.receipt || {}, { tool: name, annotations })) : false;
|
|
112
|
+
if (!ok)
|
|
113
|
+
throw new McpPreflightRefused("tools/call", "tool " + name + " is annotated destructive and no human confirmed", { receipt: state.receipt });
|
|
114
|
+
}
|
|
115
|
+
return { receipt: state.receipt };
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Wrap an MCP client in place: client.connect and client.callTool run the hooks first. Returns the same client, with
|
|
121
|
+
* client.preflight (the hooks and their state). Works on @modelcontextprotocol/sdk's Client and on anything with the same
|
|
122
|
+
* two methods; listTools() is used for the lockfile check when present.
|
|
123
|
+
*/
|
|
124
|
+
export function withPreflight(client, cc, opts = {}) {
|
|
125
|
+
const pf = mcpPreflight(cc, opts);
|
|
126
|
+
const connect = client.connect.bind(client), callTool = client.callTool.bind(client);
|
|
127
|
+
const tools = {};
|
|
128
|
+
client.connect = async function (transport, ...rest) {
|
|
129
|
+
await pf.beforeConnect(opts.serverUrl || transportUrl(transport));
|
|
130
|
+
const r = await connect(transport, ...rest);
|
|
131
|
+
if (opts.lock || opts.confirmDestructive) {
|
|
132
|
+
let list = [];
|
|
133
|
+
try {
|
|
134
|
+
const t = typeof client.listTools === "function" ? await client.listTools() : null;
|
|
135
|
+
list = (t && t.tools) || [];
|
|
136
|
+
}
|
|
137
|
+
catch (e) {
|
|
138
|
+
list = [];
|
|
139
|
+
}
|
|
140
|
+
list.forEach(function (t) { if (t && t.name)
|
|
141
|
+
tools[t.name] = t; });
|
|
142
|
+
try {
|
|
143
|
+
await pf.afterConnect(list);
|
|
144
|
+
}
|
|
145
|
+
catch (e) {
|
|
146
|
+
try {
|
|
147
|
+
if (typeof client.close === "function")
|
|
148
|
+
await client.close();
|
|
149
|
+
}
|
|
150
|
+
catch (e2) { }
|
|
151
|
+
throw e;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return r;
|
|
155
|
+
};
|
|
156
|
+
client.callTool = async function (params, ...rest) {
|
|
157
|
+
const name = params && params.name;
|
|
158
|
+
await pf.beforeToolCall(name, tools[name] && tools[name].annotations);
|
|
159
|
+
return callTool(params, ...rest);
|
|
160
|
+
};
|
|
161
|
+
client.preflight = pf;
|
|
162
|
+
return client;
|
|
163
|
+
}
|
package/dist/verify.d.ts
CHANGED
|
@@ -10,10 +10,20 @@ export function dspVerdict(m: any): {
|
|
|
10
10
|
rule: string;
|
|
11
11
|
};
|
|
12
12
|
export function rfcRootV(leafHex: any, index: any, size: any, path: any): Promise<string | null>;
|
|
13
|
+
export function tvReachV(envelope: any): any;
|
|
14
|
+
export function verifyThresholdSigners(x: any): Promise<{
|
|
15
|
+
observer_id: any;
|
|
16
|
+
network: any;
|
|
17
|
+
signature: boolean;
|
|
18
|
+
binding: boolean;
|
|
19
|
+
values_match: boolean;
|
|
20
|
+
why: never[];
|
|
21
|
+
}[]>;
|
|
13
22
|
export function trustOf(res: any, kid: any, publishedKids: any): any;
|
|
14
23
|
export function publishedKeyIds(base: any): Promise<string[]>;
|
|
15
24
|
export function verifyBundle(b: any, opts: any): Promise<any>;
|
|
16
25
|
export function verifyReceipt(rc: any, opts: any): Promise<any>;
|
|
17
26
|
export function verifyExport(x: any, opts: any): Promise<any>;
|
|
18
27
|
export function verifyResolution(x: any, opts: any): Promise<any>;
|
|
28
|
+
export function verifyDnsTxt(txt: any, opts: any): Promise<any>;
|
|
19
29
|
export function verifyDataDoc(x: any, opts: any): Promise<any>;
|
package/dist/verify.js
CHANGED
|
@@ -519,6 +519,90 @@ export async function rfcRootV(leafHex, index, size, path) {
|
|
|
519
519
|
}
|
|
520
520
|
return sn === 0 ? hex(r) : null;
|
|
521
521
|
}
|
|
522
|
+
// tv1: threshold-signed verdicts. Every signer is checked on its own: its Ed25519 signature over its envelope, the
|
|
523
|
+
// binding of its key to its identity (GitHub's RS256 token, a self-signed enrolment, or the published key directory),
|
|
524
|
+
// and the field values recomputed from its envelope. The verdict holds when signers on at least k distinct networks pass.
|
|
525
|
+
const TV_OBS_PREFIX = "crawlcheck-observation-v1\n", TV_ENROL_PREFIX = "crawlcheck-observer-enrol-v1\n";
|
|
526
|
+
function tvFinalV(u) { try {
|
|
527
|
+
const x = new URL(u);
|
|
528
|
+
return x.hostname.toLowerCase().replace(/^www\./, "") + (x.pathname.replace(/\/+$/, "") || "/");
|
|
529
|
+
}
|
|
530
|
+
catch (e) {
|
|
531
|
+
return null;
|
|
532
|
+
} }
|
|
533
|
+
export function tvReachV(envelope) {
|
|
534
|
+
return (envelope && Array.isArray(envelope.fetches) ? envelope.fetches : []).map((f) => ({ identity: String(f.identity), status: typeof f.status === "number" ? f.status : null, final: f.url_final ? tvFinalV(f.url_final) : null, failed: !!f.error }))
|
|
535
|
+
.sort((a, b) => (a.identity < b.identity ? -1 : a.identity > b.identity ? 1 : 0));
|
|
536
|
+
}
|
|
537
|
+
async function tvEd(x, msg, sig) { try {
|
|
538
|
+
const pk = await subtle.importKey("jwk", { kty: "OKP", crv: "Ed25519", x }, { name: "Ed25519" }, false, ["verify"]);
|
|
539
|
+
return await subtle.verify({ name: "Ed25519" }, pk, b64(sig), enc.encode(msg));
|
|
540
|
+
}
|
|
541
|
+
catch (e) {
|
|
542
|
+
return null;
|
|
543
|
+
} }
|
|
544
|
+
async function tvJwt(token, jwk) {
|
|
545
|
+
const p = String(token || "").split(".");
|
|
546
|
+
if (p.length !== 3 || !jwk || jwk.kty !== "RSA")
|
|
547
|
+
return { ok: false };
|
|
548
|
+
let h, c;
|
|
549
|
+
try {
|
|
550
|
+
h = JSON.parse(new TextDecoder().decode(b64(p[0])));
|
|
551
|
+
c = JSON.parse(new TextDecoder().decode(b64(p[1])));
|
|
552
|
+
}
|
|
553
|
+
catch (e) {
|
|
554
|
+
return { ok: false };
|
|
555
|
+
}
|
|
556
|
+
if (h.alg !== "RS256" || (jwk.kid && h.kid !== jwk.kid))
|
|
557
|
+
return { ok: false, claims: c };
|
|
558
|
+
try {
|
|
559
|
+
const k = await subtle.importKey("jwk", { kty: "RSA", n: jwk.n, e: jwk.e, alg: "RS256", ext: true }, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, false, ["verify"]);
|
|
560
|
+
return { ok: await subtle.verify("RSASSA-PKCS1-v1_5", k, b64(p[2]), enc.encode(p[0] + "." + p[1])), claims: c, kid: h.kid };
|
|
561
|
+
}
|
|
562
|
+
catch (e) {
|
|
563
|
+
return { ok: null, claims: c };
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
export async function verifyThresholdSigners(x) {
|
|
567
|
+
const st = x.statement || {}, want = canon(st.values || []), rows = [];
|
|
568
|
+
for (const s of Array.isArray(x.signers) ? x.signers : []) {
|
|
569
|
+
const r = { observer_id: s.observer_id, network: s.network && s.network.class || null, signature: false, binding: false, values_match: false, why: [] };
|
|
570
|
+
const ev = s.envelope || {}, key = ev.key || {};
|
|
571
|
+
r.signature = !!(await tvEd(key.x, TV_OBS_PREFIX + canon(ev), s.sig));
|
|
572
|
+
if (!r.signature)
|
|
573
|
+
r.why.push("signature does not verify under the envelope key");
|
|
574
|
+
const b = s.binding || {};
|
|
575
|
+
if (b.kind === "github_oidc") {
|
|
576
|
+
const j = await tvJwt(b.token, b.github_key), c = j.claims || {};
|
|
577
|
+
const aud = "crawlcheck-observer:" + b64u(await sha256(b64(key.x || "")));
|
|
578
|
+
r.binding = j.ok === true && c.iss === "https://token.actions.githubusercontent.com" && c.repository === b.repository && String(c.job_workflow_ref || c.workflow_ref || "").indexOf(b.workflow_ref_prefix || "\u0000") === 0 && (Array.isArray(c.aud) ? c.aud : [c.aud]).includes(aud) && (!c.runner_environment || c.runner_environment === "github-hosted");
|
|
579
|
+
r.github_kid = j.kid || null;
|
|
580
|
+
if (!r.binding)
|
|
581
|
+
r.why.push("GitHub token does not bind this key to the observer workflow");
|
|
582
|
+
}
|
|
583
|
+
else if (b.kind === "enrolled_ed25519") {
|
|
584
|
+
const q = b.enrolment && b.enrolment.request;
|
|
585
|
+
r.binding = !!(b.key && b.key.x === key.x && q && q.observer_id === s.observer_id && q.key && q.key.x === key.x && await tvEd(key.x, TV_ENROL_PREFIX + canon(q), b.enrolment.sig));
|
|
586
|
+
if (!r.binding)
|
|
587
|
+
r.why.push("the key is not the one this observer enrolled with");
|
|
588
|
+
}
|
|
589
|
+
else if (b.kind === "published_key_directory") {
|
|
590
|
+
const thumb = b64u(await sha256(enc.encode(JSON.stringify({ crv: "Ed25519", kty: "OKP", x: key.x }))));
|
|
591
|
+
r.binding = thumb === b.kid && thumb === (x.signature && x.signature.kid);
|
|
592
|
+
r.kid = thumb;
|
|
593
|
+
if (!r.binding)
|
|
594
|
+
r.why.push("key thumbprint is not the stated (published) key id");
|
|
595
|
+
}
|
|
596
|
+
else
|
|
597
|
+
r.why.push("unknown binding " + b.kind);
|
|
598
|
+
r.values_match = canon(tvReachV(ev)) === want;
|
|
599
|
+
if (!r.values_match)
|
|
600
|
+
r.why.push("the values in this envelope differ from the statement");
|
|
601
|
+
r.passes = r.signature && r.binding && r.values_match;
|
|
602
|
+
rows.push(r);
|
|
603
|
+
}
|
|
604
|
+
return rows;
|
|
605
|
+
}
|
|
522
606
|
// ── data inventories, export parts and deletion records ─────────────────────────────────────────────────────────
|
|
523
607
|
// Signed over sha256(canonical JSON of every field except sha256 and signature) with the prefix below. A deletion
|
|
524
608
|
// record carries counts per family and the digest of the sorted deleted-key list, never the data.
|
|
@@ -528,7 +612,7 @@ async function verifyDataDoc0(x) {
|
|
|
528
612
|
throw new Error("this runtime has no WebCrypto (crypto.subtle)");
|
|
529
613
|
const checks = [];
|
|
530
614
|
const C = (id, ok, why) => checks.push({ id, ok, why });
|
|
531
|
-
if (!x || !/^crawlcheck-(data-inventory|data-export-part|deletion-record|resolve|decision|snapshot|visitor|conduct|traffic-index|anchor|anchor-check|content-clock|origin|claims|read-equivalence|rights|rights-feed|mcp-scan|mcp-lock|mcp-lock-check|mcp-shadow|mcp-auth|impersonation|task-canaries|incident|revocations|high-risk|log-sth)$/.test(String(x.kind)) || !x.signature)
|
|
615
|
+
if (!x || !/^crawlcheck-(data-inventory|data-export-part|deletion-record|resolve|decision|snapshot|visitor|conduct|traffic-index|anchor|anchor-check|content-clock|origin|claims|read-equivalence|rights|rights-feed|mcp-scan|mcp-lock|mcp-lock-check|mcp-shadow|mcp-auth|impersonation|task-canaries|agent-safety-benchmark|compliance-pack|share-of-preflight|incident|revocations|high-risk|log-sth|threshold-verdict|resolve-as-of)$/.test(String(x.kind)) || !x.signature)
|
|
532
616
|
throw new Error("not a CrawlCheck data inventory, export part, deletion record, resolve answer, decision receipt, snapshot manifest, visitor verdict, conduct record, traffic index, citation anchor, content clock, origin decision, claim ledger, read-equivalence certificate, rights answer, rights-feed manifest, MCP tool scan, MCP lockfile or lockfile check");
|
|
533
617
|
const body = {};
|
|
534
618
|
for (const k of Object.keys(x))
|
|
@@ -577,6 +661,10 @@ async function verifyDataDoc0(x) {
|
|
|
577
661
|
const ps = Array.isArray(x.parts) ? x.parts : [];
|
|
578
662
|
C("parts", ps.length > 0 && ps.every((p) => /^part-\d{4}\.ndjson\.gz$/.test(String(p.path)) && /^[0-9a-f]{64}$/.test(String(p.sha256)) && p.records > 0) && ps.reduce((a, p) => a + p.records, 0) === x.records, ps.reduce((a, p) => a + (p.records || 0), 0) === x.records ? ps.length + " parts, " + x.records + " records; check each downloaded part with sha256sum against its sha256, then each line with this tool" : "the parts' record counts do not add up to records (" + x.records + ")");
|
|
579
663
|
}
|
|
664
|
+
if (x.kind === "crawlcheck-resolve" && Array.isArray(x.absences)) { // sa1: every absence states what was checked, from where and when
|
|
665
|
+
const bad = x.absences.filter((a) => !(a && a.what && (a.result === "absent" || a.result === "none") && a.at && !isNaN(Date.parse(a.at)) && a.from && a.from.observer && typeof a.scope === "string" && Array.isArray(a.checked) && a.checked.length && a.checked.every((c) => c && c.method && (a.result !== "absent" || c.status === undefined || c.status === 404 || c.status === 410))));
|
|
666
|
+
C("absences_scoped", bad.length === 0, x.absences.length + " absence record(s)" + (x.absences.length ? " (" + x.absences.map((a) => a.what).join(", ") + ")" : "") + (bad.length ? "; " + bad.length + " lack a definite check, a time, a vantage or a scope" : ", each naming what was requested, the answer, the vantage and the time"));
|
|
667
|
+
}
|
|
580
668
|
if (x.kind === "crawlcheck-resolve" && x.transparency) { // tl1: the answer's place in the Resolve transparency log
|
|
581
669
|
const t = x.transparency, leaf = hex(await sha256(cat(new Uint8Array([0]), enc.encode(canon(tlContentV(x))))));
|
|
582
670
|
C("log_leaf", leaf === t.leaf_hash, leaf === t.leaf_hash ? "the answer's decision content hashes to its log leaf " + leaf.slice(0, 16) + "…" : "the decision content hashes to " + leaf + ", the answer names " + t.leaf_hash);
|
|
@@ -596,6 +684,41 @@ async function verifyDataDoc0(x) {
|
|
|
596
684
|
else
|
|
597
685
|
C("log_inclusion", null, "pending: the leaf is queued and merged within " + (t.merged_within_minutes || 30) + " minutes; fetch the proof at " + (t.proof || "the log"));
|
|
598
686
|
}
|
|
687
|
+
if (x.kind === "crawlcheck-resolve-as-of") { // ah1: the answer in force at as_of, as signed then
|
|
688
|
+
let av = null;
|
|
689
|
+
try {
|
|
690
|
+
av = await verifyDataDoc0(x.answer);
|
|
691
|
+
}
|
|
692
|
+
catch (e) {
|
|
693
|
+
av = null;
|
|
694
|
+
}
|
|
695
|
+
C("answer_signature", !!(av && av.verified) && !!x.answer && x.answer.kind === "crawlcheck-resolve" && x.answer.domain === x.domain, av && av.verified ? "the archived answer verifies on its own (signed " + (x.answer.signature && x.answer.signature.signed_at) + ")" : "the archived answer does NOT verify");
|
|
696
|
+
const leaf = x.answer ? hex(await sha256(cat(new Uint8Array([0]), enc.encode(canon(tlContentV(x.answer)))))) : null;
|
|
697
|
+
C("answer_leaf", leaf === x.leaf_hash, leaf === x.leaf_hash ? "the answer's decision content is leaf " + String(leaf).slice(0, 16) + "…" : "the answer's content hashes to " + leaf + ", not the stated leaf");
|
|
698
|
+
const w = x.in_force || {}, t = Date.parse(x.as_of);
|
|
699
|
+
C("in_force_window", Date.parse(w.from) <= t && (w.until == null || t < Date.parse(w.until)), "as_of " + x.as_of + " falls in [" + w.from + ", " + (w.until || "now") + ")");
|
|
700
|
+
const pr = x.log_inclusion;
|
|
701
|
+
if (pr && pr.sth) {
|
|
702
|
+
const root = await rfcRootV(x.leaf_hash, pr.index, pr.batch_size, pr.path || []);
|
|
703
|
+
let sv = null;
|
|
704
|
+
try {
|
|
705
|
+
sv = await verifyDataDoc0(pr.sth);
|
|
706
|
+
}
|
|
707
|
+
catch (e) {
|
|
708
|
+
sv = null;
|
|
709
|
+
}
|
|
710
|
+
C("log_inclusion", root !== null && root === pr.sth.batch_root && !!(sv && sv.verified), root === pr.sth.batch_root ? "the leaf is in batch " + pr.batch + " of the signed log (head created " + pr.sth.created_at + ")" : "the inclusion proof does NOT reach the signed batch root");
|
|
711
|
+
}
|
|
712
|
+
else
|
|
713
|
+
C("log_inclusion", null, "not merged into the log yet; merged within 30 minutes of first appearing");
|
|
714
|
+
}
|
|
715
|
+
if (x.kind === "crawlcheck-threshold-verdict") { // tv1
|
|
716
|
+
const rows = await verifyThresholdSigners(x), k = x.threshold && x.threshold.k || 2;
|
|
717
|
+
rows.forEach((r) => C("signer:" + r.observer_id, r.passes, r.passes ? "signature, key binding (" + (x.signers.find((s) => s.observer_id === r.observer_id).binding.kind) + ") and recomputed " + (x.statement && x.statement.family) + " values all check (network " + r.network + ")" : r.why.join("; ")));
|
|
718
|
+
const nets = new Set(rows.filter((r) => r.passes).map((r) => r.network));
|
|
719
|
+
C("threshold", nets.size >= k, nets.size + " distinct network(s) signed identical values (" + [...nets].join(", ") + "); the verdict needs " + k);
|
|
720
|
+
C("key_bindings_online", null, "offline the bindings are checked against keys the document carries; online, compare the GitHub key id with " + "https://token.actions.githubusercontent.com/.well-known/jwks and enrolled keys with https://crawlcheck.io/.well-known/crawlcheck-observers.json");
|
|
721
|
+
}
|
|
599
722
|
if (x.kind === "crawlcheck-deletion-record")
|
|
600
723
|
C("counts", (x.deleted || []).reduce((a, f) => a + (f.keys || 0), 0) === x.deleted_total, "the per-family counts add up to deleted_total (" + x.deleted_total + ")");
|
|
601
724
|
const ran = checks.filter((c) => c.ok !== null), failed = checks.filter((c) => c.ok === false);
|
|
@@ -640,6 +763,38 @@ export async function verifyBundle(b, opts) { return trustOf(await verifyBundle0
|
|
|
640
763
|
export async function verifyReceipt(rc, opts) { return trustOf(await verifyReceipt0(rc), rc && rc.signature && rc.signature.kid, opts && opts.publishedKids); }
|
|
641
764
|
export async function verifyExport(x, opts) { return trustOf(await verifyExport0(x), kidOf(x), opts && opts.publishedKids); }
|
|
642
765
|
export async function verifyResolution(x, opts) { return trustOf(await verifyResolution0(x), kidOf(x), opts && opts.publishedKids); }
|
|
766
|
+
// dns1: a Resolve-over-DNS TXT record (ccr1). Strings joined without spaces; quotes from dig output are stripped.
|
|
767
|
+
async function verifyDnsTxt0(txt) {
|
|
768
|
+
if (!subtle)
|
|
769
|
+
throw new Error("this runtime has no WebCrypto (crypto.subtle)");
|
|
770
|
+
const checks = [], C = (id, ok, why) => checks.push({ id, ok, why });
|
|
771
|
+
const t = String(txt || "").trim().replace(/"\s*"/g, "").replace(/^"|"$/g, "").replace(/\;/g, ";");
|
|
772
|
+
const i = t.lastIndexOf(";s="), body = i > 0 ? t.slice(0, i) : "", sig = i > 0 ? t.slice(i + 3) : "";
|
|
773
|
+
const f = {};
|
|
774
|
+
body.split(";").forEach((p) => { const j = p.indexOf("="); if (j > 0)
|
|
775
|
+
f[p.slice(0, j)] = p.slice(j + 1); });
|
|
776
|
+
C("format", f.v === "ccr1" && !!f.d && !!f.k && !!f.x && !!sig, f.v === "ccr1" ? "ccr1 record for " + (f.d || "?") + " (" + (f.st || "?") + ")" : "not a ccr1 record");
|
|
777
|
+
if (f.x && f.k) {
|
|
778
|
+
const thumb = b64u(await sha256(enc.encode(JSON.stringify({ crv: "Ed25519", kty: "OKP", x: f.x }))));
|
|
779
|
+
C("key_id", thumb === f.k, thumb === f.k ? "public key thumbprint (RFC 7638) is the key id " + thumb.slice(0, 12) + "…" : "thumbprint " + thumb + " does not match key id " + f.k);
|
|
780
|
+
let ok = null;
|
|
781
|
+
try {
|
|
782
|
+
const pk = await subtle.importKey("jwk", { kty: "OKP", crv: "Ed25519", x: f.x }, { name: "Ed25519" }, false, ["verify"]);
|
|
783
|
+
ok = await subtle.verify({ name: "Ed25519" }, pk, b64(sig), enc.encode("crawlcheck-dns-v1\n" + body));
|
|
784
|
+
}
|
|
785
|
+
catch (e) {
|
|
786
|
+
ok = null;
|
|
787
|
+
C("signature", null, "this runtime cannot verify Ed25519: " + (e && e.message || e));
|
|
788
|
+
}
|
|
789
|
+
if (ok !== null)
|
|
790
|
+
C("signature", ok, ok ? "Ed25519 signature over the record verifies" : "signature does NOT verify over this record");
|
|
791
|
+
}
|
|
792
|
+
const fu = Date.parse(f.f || "");
|
|
793
|
+
C("fresh", isFinite(fu) ? fu > Date.now() : false, isFinite(fu) ? (fu > Date.now() ? "fresh until " + f.f : "expired at " + f.f + ": ask again") : "no fresh_until");
|
|
794
|
+
C("key_published", null, "offline this cannot be decided: compare key id " + (f.k || "?") + " with the ids published at https://crawlcheck.io/.well-known/http-message-signatures-directory");
|
|
795
|
+
return { record: f, decisions: { read: f.read || null, cite: f.cite || null, connect: f.connect || null, transact: f.transact || null }, checks };
|
|
796
|
+
}
|
|
797
|
+
export async function verifyDnsTxt(txt, opts) { const r = await verifyDnsTxt0(txt); return trustOf(r, r.record && r.record.k, opts && opts.publishedKids); }
|
|
643
798
|
export async function verifyDataDoc(x, opts) { return trustOf(await verifyDataDoc0(x), kidOf(x), opts && opts.publishedKids); }
|
|
644
799
|
const isMain = typeof process !== "undefined" && !!(process.argv && process.argv[1]) && await (async () => { try {
|
|
645
800
|
const { pathToFileURL } = await import("node:url");
|
|
@@ -651,9 +806,32 @@ catch (e) {
|
|
|
651
806
|
} })();
|
|
652
807
|
if (isMain) {
|
|
653
808
|
const online = process.argv.includes("--online");
|
|
809
|
+
if (process.argv.includes("--dns")) { // dns1: a Resolve-over-DNS TXT record
|
|
810
|
+
let txt = process.argv.slice(2).filter((a) => a !== "--online" && a !== "--dns").join(" ");
|
|
811
|
+
if (!txt) {
|
|
812
|
+
const { readFileSync } = await import("node:fs");
|
|
813
|
+
txt = readFileSync(0, "utf8");
|
|
814
|
+
}
|
|
815
|
+
let kd;
|
|
816
|
+
if (online) {
|
|
817
|
+
try {
|
|
818
|
+
kd = await publishedKeyIds();
|
|
819
|
+
}
|
|
820
|
+
catch (e) {
|
|
821
|
+
console.log("could not read the published key directory: " + e.message);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
const r = await verifyDnsTxt(txt, { publishedKids: kd });
|
|
825
|
+
console.log("CrawlCheck Resolve over DNS: " + (r.record.d || "?") + " " + (r.record.st || "?") + " · read " + r.decisions.read + " · cite " + r.decisions.cite + " · connect " + r.decisions.connect + " · transact " + r.decisions.transact);
|
|
826
|
+
for (const c of r.checks)
|
|
827
|
+
console.log((c.ok === true ? "PASS" : c.ok === false ? "FAIL" : " -- ") + " " + c.id.padEnd(23) + c.why);
|
|
828
|
+
console.log(r.summary);
|
|
829
|
+
console.log(r.accepted ? "ACCEPTED" : "NOT ACCEPTED" + (r.integrity_valid && r.issuer_trusted === null ? ": issuer unknown offline (run with --online)" : ""));
|
|
830
|
+
process.exit(r.checks.some((c) => c.ok === false) ? 1 : 0);
|
|
831
|
+
}
|
|
654
832
|
const f = process.argv.slice(2).filter((a) => a !== "--online")[0];
|
|
655
833
|
if (!f) {
|
|
656
|
-
console.log("usage: node crawlcheck-verify.mjs <bundle.json | receipt.json | export.json | resolution.json | deletion.json | answer.json | decision.json | manifest.json>");
|
|
834
|
+
console.log("usage: node crawlcheck-verify.mjs [--online] [--dns \"<ccr1 TXT record>\"] <bundle.json | receipt.json | export.json | resolution.json | deletion.json | answer.json | decision.json | manifest.json>");
|
|
657
835
|
process.exit(2);
|
|
658
836
|
}
|
|
659
837
|
const { readFile } = await import("node:fs/promises");
|
|
@@ -661,7 +839,7 @@ if (isMain) {
|
|
|
661
839
|
const isRc = doc && (doc.kind === "crawlcheck-remediation-receipt" || (doc.receipt && doc.receipt.kind === "crawlcheck-remediation-receipt"));
|
|
662
840
|
const isEx = doc && doc.kind === "crawlcheck-evidence-export";
|
|
663
841
|
const isDr = doc && doc.kind === "crawlcheck-dispute-resolution";
|
|
664
|
-
const isDt = doc && /^crawlcheck-(data-inventory|data-export-part|deletion-record|resolve|decision|snapshot|visitor|conduct|traffic-index|anchor|anchor-check|content-clock|origin|claims|read-equivalence|rights|rights-feed|mcp-scan|mcp-lock|mcp-lock-check|mcp-shadow|mcp-auth|impersonation|task-canaries|incident|revocations|high-risk|log-sth)$/.test(String(doc.kind));
|
|
842
|
+
const isDt = doc && /^crawlcheck-(data-inventory|data-export-part|deletion-record|resolve|decision|snapshot|visitor|conduct|traffic-index|anchor|anchor-check|content-clock|origin|claims|read-equivalence|rights|rights-feed|mcp-scan|mcp-lock|mcp-lock-check|mcp-shadow|mcp-auth|impersonation|task-canaries|agent-safety-benchmark|compliance-pack|share-of-preflight|incident|revocations|high-risk|log-sth|threshold-verdict|resolve-as-of)$/.test(String(doc.kind));
|
|
665
843
|
let kids;
|
|
666
844
|
if (online) {
|
|
667
845
|
try {
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Preflight as an MCP client extension: the official MCP TypeScript client, wrapped so `connect` and `callTool` are
|
|
2
|
+
// decided before anything is sent. Run: node mcp-preflight-demo.mjs (needs @modelcontextprotocol/sdk and @crawlcheck/sdk).
|
|
3
|
+
//
|
|
4
|
+
// 1. A server on a domain nothing vouches for -> the connect is refused; no packet leaves the client.
|
|
5
|
+
// 2. CrawlCheck's own MCP server (connect = require_confirmation) -> refused when nobody confirms, connected when a
|
|
6
|
+
// person says yes; the signed receipt is shown and verifies offline.
|
|
7
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
8
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
9
|
+
import { CrawlCheck, withPreflight, McpPreflightRefused } from "@crawlcheck/sdk";
|
|
10
|
+
|
|
11
|
+
const cc = new CrawlCheck();
|
|
12
|
+
const sent = [];
|
|
13
|
+
const watchedFetch = async (u, init) => { sent.push((init && init.method || "GET") + " " + String(u)); return fetch(u, init); }; // every request the transport makes
|
|
14
|
+
|
|
15
|
+
async function attempt(label, url, opts) {
|
|
16
|
+
const client = withPreflight(new Client({ name: "preflight-demo", version: "1.0.0" }), cc, opts);
|
|
17
|
+
const before = sent.length;
|
|
18
|
+
try {
|
|
19
|
+
await client.connect(new StreamableHTTPClientTransport(new URL(url), { fetch: watchedFetch }));
|
|
20
|
+
const tools = await client.listTools();
|
|
21
|
+
console.log(label + ": CONNECTED, " + tools.tools.length + " tools listed; receipt " + client.preflight.state.receipt.decision + " sha256 " + client.preflight.state.receipt.sha256.slice(0, 16));
|
|
22
|
+
await client.close();
|
|
23
|
+
} catch (e) {
|
|
24
|
+
if (!(e instanceof McpPreflightRefused)) throw e;
|
|
25
|
+
console.log(label + ": REFUSED at " + e.stage + " - " + e.why);
|
|
26
|
+
console.log(" decision " + (e.receipt && e.receipt.decision) + ", receipt sha256 " + (e.receipt && e.receipt.sha256 && e.receipt.sha256.slice(0, 16)) + ", verifies offline: " + (e.verification && e.verification.verified));
|
|
27
|
+
}
|
|
28
|
+
console.log(" requests the MCP transport sent: " + (sent.length - before));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
await attempt("1. mcp.example.com (no record)", "https://mcp.example.com/mcp", {});
|
|
32
|
+
await attempt("2a. crawlcheck.io/mcp, nobody to confirm", "https://crawlcheck.io/mcp", {});
|
|
33
|
+
await attempt("2b. crawlcheck.io/mcp, a person confirms", "https://crawlcheck.io/mcp", { confirm: async (r) => { console.log(" confirm? " + r.decision + ": " + ((r.steps || [])[0] || {}).why); return true; } });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crawlcheck/sdk",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.9",
|
|
4
4
|
"description": "Typed client for the CrawlCheck API (generated from its OpenAPI document) and the zero-dependency offline verifier for evidence bundles and remediation receipts.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"files": [
|
|
15
15
|
"dist",
|
|
16
16
|
"test",
|
|
17
|
+
"examples",
|
|
17
18
|
"README.md"
|
|
18
19
|
],
|
|
19
20
|
"engines": {
|
package/test/mcp.test.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import test from "node:test"; import assert from "node:assert/strict";
|
|
2
|
+
import { withPreflight, mcpPreflight, McpPreflightRefused, transportUrl, lockFromTools } from "../dist/index.js";
|
|
3
|
+
|
|
4
|
+
// A CrawlCheck client double: guard() answers from a table, no network.
|
|
5
|
+
function cc(table) {
|
|
6
|
+
const calls = [];
|
|
7
|
+
return { calls, async guard(host, action, opts) {
|
|
8
|
+
calls.push({ host, action, template: opts.template });
|
|
9
|
+
const d = table[host] || "unsupported";
|
|
10
|
+
const receipt = { kind: "crawlcheck-decision", domain: host, action, decision: d, expires_at: new Date(Date.now() + 3600e3).toISOString(), steps: [{ why: "table" }], sha256: "x", signature: { kid: "k" } };
|
|
11
|
+
let proceed = d === "allow" || (d === "warn" && !!opts.allowWarn);
|
|
12
|
+
if (d === "require_confirmation" && opts.confirm) proceed = !!(await opts.confirm(receipt));
|
|
13
|
+
return { proceed, receipt, verification: { verified: true, accepted: true, integrity_valid: true, issuer_trusted: true } };
|
|
14
|
+
} };
|
|
15
|
+
}
|
|
16
|
+
// An MCP client double with the official Client's surface.
|
|
17
|
+
function mcpClient(tools) {
|
|
18
|
+
const sent = [];
|
|
19
|
+
return { sent, closed: false,
|
|
20
|
+
async connect(transport) { sent.push("initialize " + transportUrl(transport)); },
|
|
21
|
+
async listTools() { return { tools }; },
|
|
22
|
+
async callTool(p) { sent.push("tools/call " + p.name); return { content: [] }; },
|
|
23
|
+
async close() { this.closed = true; } };
|
|
24
|
+
}
|
|
25
|
+
const T = (url) => ({ _url: new URL(url) });
|
|
26
|
+
const TOOLS = [{ name: "search", description: "find", inputSchema: { type: "object" }, annotations: { readOnlyHint: true } }, { name: "delete_repo", description: "rm", inputSchema: { type: "object" }, annotations: { destructiveHint: true } }];
|
|
27
|
+
|
|
28
|
+
test("connect to a host nothing vouches for is refused before initialize", async () => {
|
|
29
|
+
const c = withPreflight(mcpClient(TOOLS), cc({}));
|
|
30
|
+
await assert.rejects(() => c.connect(T("https://mcp.example.com/mcp")), (e) => e instanceof McpPreflightRefused && e.stage === "connect" && /nothing vouches/.test(e.why) && e.receipt.decision === "unsupported");
|
|
31
|
+
assert.deepEqual(c.sent, []); // no packet left the client
|
|
32
|
+
});
|
|
33
|
+
test("block refuses the connect", async () => {
|
|
34
|
+
const c = withPreflight(mcpClient(TOOLS), cc({ "evil.example": "block" }));
|
|
35
|
+
await assert.rejects(() => c.connect(T("https://evil.example/mcp")), (e) => e.stage === "connect" && e.receipt.decision === "block");
|
|
36
|
+
assert.deepEqual(c.sent, []);
|
|
37
|
+
});
|
|
38
|
+
test("allow connects; the host is decided once with the safe_mcp_tool template", async () => {
|
|
39
|
+
const k = cc({ "good.example": "allow" }); const c = withPreflight(mcpClient(TOOLS), k);
|
|
40
|
+
await c.connect(T("https://good.example/mcp"));
|
|
41
|
+
await c.callTool({ name: "search", arguments: {} });
|
|
42
|
+
assert.deepEqual(c.sent, ["initialize https://good.example/mcp", "tools/call search"]);
|
|
43
|
+
assert.equal(k.calls.length, 1); assert.equal(k.calls[0].template, "safe_mcp_tool"); assert.equal(k.calls[0].action, "connect");
|
|
44
|
+
});
|
|
45
|
+
test("require_confirmation: refused without a confirm, connects when a person says yes", async () => {
|
|
46
|
+
const k = cc({ "ask.example": "require_confirmation" });
|
|
47
|
+
await assert.rejects(() => withPreflight(mcpClient(TOOLS), k).connect(T("https://ask.example/mcp")), (e) => /human must confirm/.test(e.why));
|
|
48
|
+
let seen = null; const c = withPreflight(mcpClient(TOOLS), k, { confirm: async (r) => { seen = r; return true; } });
|
|
49
|
+
await c.connect(T("https://ask.example/mcp")); assert.equal(seen.decision, "require_confirmation"); assert.equal(c.sent.length, 1);
|
|
50
|
+
});
|
|
51
|
+
test("lockfile: changed tools close the client again; a tool outside the lock is refused", async () => {
|
|
52
|
+
const lock = await lockFromTools([TOOLS[0]]);
|
|
53
|
+
const c = withPreflight(mcpClient(TOOLS), cc({ "good.example": "allow" }), { lock });
|
|
54
|
+
await assert.rejects(() => c.connect(T("https://good.example/mcp")), (e) => e.stage === "connect" && /differ from the approved lockfile/.test(e.why) && e.lock.added.includes("delete_repo"));
|
|
55
|
+
assert.equal(c.closed, true);
|
|
56
|
+
const c2 = withPreflight(mcpClient([TOOLS[0]]), cc({ "good.example": "allow" }), { lock });
|
|
57
|
+
await c2.connect(T("https://good.example/mcp")); await c2.callTool({ name: "search" });
|
|
58
|
+
await assert.rejects(() => c2.callTool({ name: "delete_repo" }), (e) => e.stage === "tools/call" && /not in the approved lockfile/.test(e.why));
|
|
59
|
+
assert.deepEqual(c2.sent, ["initialize https://good.example/mcp", "tools/call search"]);
|
|
60
|
+
});
|
|
61
|
+
test("destructive tool asks for confirmation", async () => {
|
|
62
|
+
const c = withPreflight(mcpClient(TOOLS), cc({ "good.example": "allow" }), { confirmDestructive: true, confirm: async (r) => r.tool !== "delete_repo" });
|
|
63
|
+
await c.connect(T("https://good.example/mcp"));
|
|
64
|
+
await c.callTool({ name: "search" });
|
|
65
|
+
await assert.rejects(() => c.callTool({ name: "delete_repo" }), (e) => /annotated destructive/.test(e.why));
|
|
66
|
+
assert.deepEqual(c.sent, ["initialize https://good.example/mcp", "tools/call search"]);
|
|
67
|
+
});
|
|
68
|
+
test("expired connect receipt is decided again before the next call", async () => {
|
|
69
|
+
const k = cc({ "good.example": "allow" }); const c = withPreflight(mcpClient(TOOLS), k, { maxAgeMs: 1 });
|
|
70
|
+
await c.connect(T("https://good.example/mcp")); await new Promise((r) => setTimeout(r, 5));
|
|
71
|
+
await c.callTool({ name: "search" }); assert.equal(k.calls.length, 2);
|
|
72
|
+
});
|
|
73
|
+
test("local stdio server: allowed by default, refusable", async () => {
|
|
74
|
+
const c = withPreflight(mcpClient(TOOLS), cc({})); await c.connect({ kind: "stdio" }); assert.equal(c.preflight.state.host, null);
|
|
75
|
+
await assert.rejects(() => withPreflight(mcpClient(TOOLS), cc({}), { local: "refuse" }).connect({ kind: "stdio" }), (e) => /local/.test(e.why));
|
|
76
|
+
});
|
|
77
|
+
test("bare hooks work without a client object", async () => {
|
|
78
|
+
const h = mcpPreflight(cc({ "good.example": "allow" }));
|
|
79
|
+
const r = await h.beforeConnect("https://good.example/mcp"); assert.equal(r.host, "good.example");
|
|
80
|
+
assert.equal((await h.beforeToolCall("search")).receipt.decision, "allow");
|
|
81
|
+
});
|