@crawlcheck/sdk 1.0.9 → 1.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/dist/index.d.ts CHANGED
@@ -195,8 +195,20 @@ 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; /** The exact operation this decision is for (method + url, or an MCP tool); signed into the receipt. */
200
+ operation?: Operation;
199
201
  }
202
+ /** What a receipt can be scoped to. Send only the keys that apply; CrawlCheck signs them back exactly as sent. https://crawlcheck.io/spec/guard#operation */
203
+ export interface Operation {
204
+ method?: "GET" | "HEAD" | "POST" | "PUT" | "PATCH" | "DELETE" | "OPTIONS";
205
+ url?: string;
206
+ tool?: string;
207
+ tool_schema_sha256?: string;
208
+ scopes?: string[];
209
+ }
210
+ /** The operation for calling an MCP tool: its name, the sha256 of its definition (as in the lockfile) and the scopes you will use. */
211
+ export declare function toolOperation(tool: McpTool, scopes?: string[]): Promise<Operation>;
200
212
  /** A signed crawlcheck-decision receipt. Verify it with verifyDocument (or let guard() do it). */
201
213
  export interface DecisionReceipt {
202
214
  kind: "crawlcheck-decision";
@@ -220,8 +232,61 @@ export interface GuardOptions extends PreflightOptions {
220
232
  allowWarn?: boolean;
221
233
  /** Called on "require_confirmation"; proceed only if it resolves true. Without it, require_confirmation does not proceed. */
222
234
  confirm?: (receipt: DecisionReceipt) => boolean | Promise<boolean>;
223
- /** Also require the signing key to be in the published key directory (default true). */
235
+ /** Read the published key directory (default true). With false, pass publishedKids: a guard never accepts a receipt without checking who signed it. */
224
236
  onlineKeys?: boolean;
237
+ /** Pinned CrawlCheck key ids, for offline use with onlineKeys: false. */
238
+ publishedKids?: string[];
239
+ /** Testing clock (ms since epoch). Default Date.now(). */
240
+ now?: number;
241
+ }
242
+ /** The longest a receipt may stay valid, per action (seconds). A receipt that claims longer is refused. Published at https://crawlcheck.io/spec/guard */
243
+ export declare const ACTION_MAX_LIFETIME_S: Record<string, number>;
244
+ /** The most clock skew a receipt may claim (seconds). */
245
+ export declare const MAX_CLOCK_SKEW_S = 300;
246
+ export interface DecisionWant {
247
+ domain: string;
248
+ action: Action | string;
249
+ nonce?: string | null;
250
+ now?: number;
251
+ operation?: Operation | null;
252
+ }
253
+ /**
254
+ * Is this receipt about the operation you are about to perform? Checks the scope only (kind, domain, action, nonce,
255
+ * time window, per-action lifetime, known decision, resolve binding); the signature and issuer are checked by
256
+ * verifyDocument. guard() runs both. https://crawlcheck.io/spec/guard
257
+ */
258
+ export declare function checkDecision(receipt: unknown, want: DecisionWant): {
259
+ ok: boolean;
260
+ why: string[];
261
+ };
262
+ export interface GuardResult {
263
+ proceed: boolean;
264
+ receipt: DecisionReceipt | null;
265
+ verification: (DocumentVerification & Trust) | null;
266
+ header: Record<string, string> | null;
267
+ why: string[];
268
+ split_view?: LogFork[];
269
+ }
270
+ export interface GuardedFetchOptions extends GuardOptions {
271
+ action?: Action;
272
+ init?: RequestInit;
273
+ maxHops?: number;
274
+ fetch?: typeof fetch;
275
+ }
276
+ export interface GuardedHop {
277
+ url: string;
278
+ domain: string;
279
+ proceed: boolean;
280
+ decision: string | null;
281
+ why: string[];
282
+ status?: number;
283
+ }
284
+ export interface GuardedFetchResult {
285
+ proceed: boolean;
286
+ response: Response | null;
287
+ url: string;
288
+ hops: GuardedHop[];
289
+ why: string[];
225
290
  }
226
291
  export interface LogHead {
227
292
  kind: "crawlcheck-log-sth";
@@ -499,14 +564,20 @@ export declare class CrawlCheck {
499
564
  preflight(domain: string, action?: Action, opts?: PreflightOptions): Promise<DecisionReceipt>;
500
565
  /** Verify a signed document here. With onlineKeys (default), its signing key must also be in the published directory. */
501
566
  verifyDocument(doc: unknown, onlineKeys?: boolean): Promise<DocumentVerification & Trust>;
502
- /** Preflight, verify the receipt, and decide. Proceeds on allow; warn only with allowWarn; require_confirmation only if confirm() says yes; block and unsupported never. */
503
- guard(domain: string, action?: Action, opts?: GuardOptions): Promise<{
504
- proceed: boolean;
505
- receipt: DecisionReceipt;
506
- verification: DocumentVerification & Trust;
507
- header: Record<string, string> | null;
508
- split_view?: LogFork[];
509
- }>;
567
+ /**
568
+ * Preflight, verify the receipt, check it is for THIS operation, and decide. Proceeds on allow; warn only with allowWarn;
569
+ * require_confirmation only if confirm() says yes; block, unsupported and every failure never (fail closed: an error,
570
+ * an unreachable CrawlCheck, a malformed, foreign, expired, replayed or out-of-scope receipt all return proceed false
571
+ * with why[]). Passes the acceptance suite at https://crawlcheck.io/spec/guard
572
+ */
573
+ guard(domain: string, action?: Action, opts?: GuardOptions): Promise<GuardResult>;
574
+ /**
575
+ * fetch() with the check where the action runs: guard() before the first request, and again before every redirect hop
576
+ * to a new domain. The site is contacted only after its domain proceeds; a refused hop is never requested. Redirects
577
+ * are followed by hand (GET/HEAD only), to http(s) only, at most maxHops (10). The receipt header is sent only to the
578
+ * domain it names. In a browser a cross-origin redirect hides its target, so it is refused rather than followed blind.
579
+ */
580
+ guardedFetch(url: string, opts?: GuardedFetchOptions): Promise<GuardedFetchResult>;
510
581
  /** 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. */
511
582
  /** Make a signed lockfile for an MCP server (CrawlCheck reads tools/list; no tool is called). Save it; check with checkLock before every connect. */
512
583
  mcpLock(url: string): Promise<McpLock & JsonObject>;
package/dist/index.js CHANGED
@@ -38,6 +38,7 @@ export function normalizeDomain(v) {
38
38
  }
39
39
  /** { "CrawlCheck-Receipt": "v=1; sha256=..." } for a decision receipt. Send it only to the domain the receipt names, before expires_at. */
40
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(""); }
41
42
  async function sha256Hex(s) {
42
43
  const b = await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(s));
43
44
  return Array.from(new Uint8Array(b), (x) => x.toString(16).padStart(2, "0")).join("");
@@ -64,6 +65,55 @@ export async function checkLock(lock, tools) {
64
65
  const same = lock?.tools_sha256 === now.tools_sha256 && !added.length && !removed.length && !modified.length;
65
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 };
66
67
  }
68
+ /** The operation for calling an MCP tool: its name, the sha256 of its definition (as in the lockfile) and the scopes you will use. */
69
+ export async function toolOperation(tool, scopes) { const o = { tool: String(tool?.name ?? ""), tool_schema_sha256: await lockToolHash(tool) }; if (scopes && scopes.length)
70
+ o.scopes = scopes.slice(); return o; }
71
+ /** The longest a receipt may stay valid, per action (seconds). A receipt that claims longer is refused. Published at https://crawlcheck.io/spec/guard */
72
+ export const ACTION_MAX_LIFETIME_S = { read: 86400, cite: 86400, connect: 3600, transact: 300, administer: 300 };
73
+ /** The most clock skew a receipt may claim (seconds). */
74
+ export const MAX_CLOCK_SKEW_S = 300;
75
+ const DECISIONS = ["allow", "warn", "require_confirmation", "block", "unsupported"];
76
+ /**
77
+ * Is this receipt about the operation you are about to perform? Checks the scope only (kind, domain, action, nonce,
78
+ * time window, per-action lifetime, known decision, resolve binding); the signature and issuer are checked by
79
+ * verifyDocument. guard() runs both. https://crawlcheck.io/spec/guard
80
+ */
81
+ export function checkDecision(receipt, want) {
82
+ const why = [], r = receipt;
83
+ if (!r || typeof r !== "object" || Array.isArray(r))
84
+ return { ok: false, why: ["no receipt: CrawlCheck sent " + (r === null || r === undefined ? "nothing" : typeof r)] };
85
+ if (r.kind !== "crawlcheck-decision" || r.v !== 1)
86
+ why.push("not a v1 decision receipt (kind " + r.kind + ", v " + r.v + ")");
87
+ const wd = normalizeDomain(want.domain);
88
+ if (!wd || normalizeDomain(r.domain) !== wd)
89
+ why.push("the receipt is for " + r.domain + ", not " + want.domain);
90
+ if (String(r.action) !== String(want.action))
91
+ why.push("the receipt is for action " + r.action + ", not " + want.action);
92
+ if (want.operation && canon(r.operation ?? null) !== canon(want.operation))
93
+ why.push(r.operation ? "the receipt is for another operation (" + canon(r.operation).slice(0, 120) + ")" : "the receipt is not scoped to an operation, and this request named one");
94
+ if (want.nonce && r.nonce !== want.nonce)
95
+ why.push(r.nonce ? "the receipt carries another request's nonce (replayed)" : "the receipt does not carry the nonce this request sent");
96
+ const now = want.now ?? Date.now(), skew = Math.min(Math.max(Number(r.clock_skew_s) || 0, 0), MAX_CLOCK_SKEW_S) * 1000;
97
+ const at = Date.parse(r.decided_at), exp = Date.parse(r.expires_at);
98
+ if (!isFinite(at) || !isFinite(exp))
99
+ why.push("decided_at or expires_at missing");
100
+ else {
101
+ if (now > exp + skew)
102
+ why.push("expired at " + r.expires_at);
103
+ if (at > now + skew)
104
+ why.push("decided in the future (" + r.decided_at + "): check the clock");
105
+ const max = ACTION_MAX_LIFETIME_S[String(want.action)];
106
+ if (max === undefined)
107
+ why.push("unknown action " + want.action);
108
+ else if (exp - at > max * 1000 + 1000)
109
+ why.push("valid for " + Math.round((exp - at) / 1000) + " s; a " + want.action + " receipt may last at most " + max + " s");
110
+ }
111
+ if (DECISIONS.indexOf(r.decision) < 0)
112
+ why.push("unknown decision " + r.decision);
113
+ if (!r.resolve || !/^[0-9a-f]{64}$/.test(String(r.resolve.sha256 || "")))
114
+ why.push("the receipt names no resolve answer");
115
+ return { ok: why.length === 0, why };
116
+ }
67
117
  export const LOG_ID = L.LOG_ID;
68
118
  /** A new, empty transparency-log store (plain JSON: persist it between runs). */
69
119
  export const newLogStore = L.newLogStore;
@@ -199,29 +249,125 @@ export class CrawlCheck {
199
249
  b.template = opts.template;
200
250
  if (opts.policy)
201
251
  b.policy = opts.policy;
252
+ if (opts.nonce)
253
+ b.nonce = opts.nonce;
254
+ if (opts.operation)
255
+ b.operation = opts.operation;
202
256
  return this.post("/api/v1/preflight", b);
203
257
  }
204
258
  /** Verify a signed document here. With onlineKeys (default), its signing key must also be in the published directory. */
205
259
  async verifyDocument(doc, onlineKeys = true) {
206
260
  return withTrust(await V.verifyDataDoc(doc), doc?.signature?.kid, onlineKeys ? await this.publishedKeyIds() : null);
207
261
  }
208
- /** Preflight, verify the receipt, and decide. Proceeds on allow; warn only with allowWarn; require_confirmation only if confirm() says yes; block and unsupported never. */
262
+ /**
263
+ * Preflight, verify the receipt, check it is for THIS operation, and decide. Proceeds on allow; warn only with allowWarn;
264
+ * require_confirmation only if confirm() says yes; block, unsupported and every failure never (fail closed: an error,
265
+ * an unreachable CrawlCheck, a malformed, foreign, expired, replayed or out-of-scope receipt all return proceed false
266
+ * with why[]). Passes the acceptance suite at https://crawlcheck.io/spec/guard
267
+ */
209
268
  async guard(domain, action = "read", opts = {}) {
210
- const receipt = await this.preflight(domain, action, opts);
211
- if (this.log.forks.length) { // the log has shown this client two contradicting histories: no answer from it is trusted for action
212
- const verification = await this.verifyDocument(receipt, opts.onlineKeys ?? true);
213
- return { proceed: false, receipt, verification, header: null, split_view: this.log.forks.slice() };
269
+ const nonce = opts.nonce || randomNonce();
270
+ const no = (why, receipt = null, verification = null) => ({ proceed: false, receipt, verification, header: null, why });
271
+ let receipt;
272
+ try {
273
+ receipt = await this.preflight(domain, action, Object.assign({}, opts, { nonce }));
274
+ }
275
+ catch (e) {
276
+ return no(["CrawlCheck did not give a decision: " + (e?.message || e)]);
214
277
  }
215
- const verification = await this.verifyDocument(receipt, opts.onlineKeys ?? true);
216
- if (!verification.accepted && (opts.onlineKeys ?? true))
217
- return { proceed: false, receipt, verification, header: null };
218
- if (!verification.verified)
219
- return { proceed: false, receipt, verification, header: null };
278
+ if (this.log.forks.length)
279
+ return Object.assign(no(["the transparency log has shown this client two histories (split view)"], receipt), { split_view: this.log.forks.slice() });
280
+ const scope = checkDecision(receipt, { domain, action, nonce, now: opts.now, operation: opts.operation });
281
+ if (!receipt || typeof receipt !== "object")
282
+ return no(scope.why);
283
+ const online = opts.onlineKeys ?? true;
284
+ if (!online && !(Array.isArray(opts.publishedKids) && opts.publishedKids.length))
285
+ return no(["onlineKeys is false and no publishedKids were pinned: the signer cannot be checked"], receipt);
286
+ let verification;
287
+ try {
288
+ verification = withTrust(await V.verifyDataDoc(receipt), receipt?.signature?.kid, online ? await this.publishedKeyIds() : opts.publishedKids);
289
+ }
290
+ catch (e) {
291
+ return no(["the receipt could not be verified: " + (e?.message || e)].concat(scope.why), receipt);
292
+ }
293
+ const why = scope.why.slice();
294
+ if (!verification.integrity_valid)
295
+ why.push("integrity failed: " + verification.checks.filter((c) => c.ok === false).map((c) => c.id).join(", "));
296
+ if (verification.issuer_trusted !== true)
297
+ why.push("signed by a key that is not in CrawlCheck's published key directory");
298
+ if (why.length)
299
+ return no(why, receipt, verification);
220
300
  const d = receipt.decision;
221
301
  let proceed = d === "allow" || (d === "warn" && !!opts.allowWarn);
222
- if (d === "require_confirmation" && opts.confirm)
223
- proceed = !!(await opts.confirm(receipt));
224
- return { proceed, receipt, verification, header: proceed ? receiptHeader(receipt) : null };
302
+ if (d === "require_confirmation") {
303
+ if (opts.confirm) {
304
+ try {
305
+ proceed = (await opts.confirm(receipt)) === true;
306
+ }
307
+ catch {
308
+ proceed = false;
309
+ }
310
+ }
311
+ }
312
+ if (!proceed)
313
+ 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);
314
+ return { proceed, receipt, verification, header: proceed ? receiptHeader(receipt) : null, why };
315
+ }
316
+ /**
317
+ * fetch() with the check where the action runs: guard() before the first request, and again before every redirect hop
318
+ * to a new domain. The site is contacted only after its domain proceeds; a refused hop is never requested. Redirects
319
+ * are followed by hand (GET/HEAD only), to http(s) only, at most maxHops (10). The receipt header is sent only to the
320
+ * domain it names. In a browser a cross-origin redirect hides its target, so it is refused rather than followed blind.
321
+ */
322
+ async guardedFetch(url, opts = {}) {
323
+ const action = opts.action ?? "read", max = opts.maxHops ?? 10, f = opts.fetch ? opts.fetch.bind(globalThis) : this.f;
324
+ const init = Object.assign({}, opts.init || {}), method = String(init.method || "GET").toUpperCase();
325
+ const hops = [], seen = new Map();
326
+ let cur = url;
327
+ for (let n = 0; n <= max; n++) {
328
+ let u;
329
+ try {
330
+ u = new URL(cur);
331
+ }
332
+ catch {
333
+ return { proceed: false, response: null, url: cur, hops, why: ["not a URL: " + cur] };
334
+ }
335
+ if (u.protocol !== "https:" && u.protocol !== "http:")
336
+ return { proceed: false, response: null, url: cur, hops, why: ["refused a " + u.protocol + " URL"] };
337
+ const dom = normalizeDomain(u.hostname);
338
+ if (!dom)
339
+ return { proceed: false, response: null, url: cur, hops, why: ["not a public domain name: " + u.hostname] };
340
+ const op = { method: method, url: cur }; // op1: each hop's receipt names that exact request
341
+ let g = seen.get(method + " " + cur);
342
+ if (!g) {
343
+ g = await this.guard(dom, action, Object.assign({}, opts, { operation: op }));
344
+ seen.set(method + " " + cur, g);
345
+ }
346
+ const hop = { url: cur, domain: dom, proceed: g.proceed, decision: g.receipt ? String(g.receipt.decision) : null, why: g.why };
347
+ hops.push(hop);
348
+ if (!g.proceed)
349
+ return { proceed: false, response: null, url: cur, hops, why: [dom + ": " + (g.why.join("; ") || "refused")] };
350
+ const h = new Headers(init.headers || {});
351
+ if (g.header)
352
+ for (const [k, v] of Object.entries(g.header))
353
+ h.set(k, v);
354
+ const res = await f(cur, Object.assign({}, init, { headers: h, redirect: "manual" }));
355
+ hop.status = res.status;
356
+ if (res.type === "opaqueredirect")
357
+ return { proceed: false, response: null, url: cur, hops, why: ["the runtime hid the redirect target, so it cannot be checked"] };
358
+ const loc = res.headers.get("location");
359
+ if (![301, 302, 303, 307, 308].includes(res.status) || !loc)
360
+ return { proceed: true, response: res, url: cur, hops, why: [] };
361
+ if (method !== "GET" && method !== "HEAD")
362
+ return { proceed: true, response: res, url: cur, hops, why: ["redirect not followed for " + method] };
363
+ try {
364
+ cur = new URL(loc, cur).toString();
365
+ }
366
+ catch {
367
+ return { proceed: false, response: null, url: cur, hops, why: ["unreadable redirect target " + loc] };
368
+ }
369
+ }
370
+ return { proceed: false, response: null, url: cur, hops, why: ["more than " + max + " redirects"] };
225
371
  }
226
372
  /** 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. */
227
373
  /** 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.js CHANGED
@@ -64,7 +64,8 @@ export function mcpPreflight(cc, opts = {}) {
64
64
  state.decidedAt = Date.now();
65
65
  if (!g.proceed) {
66
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 + ")" : "");
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 + ")" : "");
68
69
  throw new McpPreflightRefused("connect", why, { receipt: g.receipt, verification: g.verification });
69
70
  }
70
71
  return g;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crawlcheck/sdk",
3
- "version": "1.0.9",
3
+ "version": "1.2.0",
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",
@@ -42,5 +42,6 @@
42
42
  ],
43
43
  "scripts": {
44
44
  "test": "node --test test/*.test.js"
45
- }
45
+ },
46
+ "license": "MIT"
46
47
  }