@crawlcheck/sdk 1.2.0 → 1.4.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.js +7 -5
- package/dist/mcp.d.ts +3 -2
- package/dist/mcp.js +27 -14
- package/dist/verify.d.ts +2 -1
- package/dist/verify.js +35 -7
- package/package.json +1 -1
- package/test/fixtures/guard-acceptance.json +610 -430
- package/test/mcp.test.js +21 -1
package/dist/index.js
CHANGED
|
@@ -2,12 +2,14 @@ import * as V from "./verify.js";
|
|
|
2
2
|
import * as L from "./log.js";
|
|
3
3
|
import * as S from "./staple.js";
|
|
4
4
|
import * as M from "./mcp.js";
|
|
5
|
-
function withTrust(v, kid, published) {
|
|
5
|
+
function withTrust(v, kid, published, related = []) {
|
|
6
6
|
let checks = v.checks;
|
|
7
7
|
let issuer = null;
|
|
8
8
|
if (published) {
|
|
9
|
-
|
|
10
|
-
const
|
|
9
|
+
// kp3: keys are separated by purpose, so a decision's resolve answer or an answer's log head can carry another key; every one must be published
|
|
10
|
+
const miss = related.filter((k) => k !== kid && !published.includes(k));
|
|
11
|
+
issuer = !!kid && published.includes(kid) && miss.length === 0;
|
|
12
|
+
const kc = { id: "key_published", ok: issuer, why: issuer ? "key id " + kid + " is in the published key directory" + (related.length ? ", and so is every key it leans on" : "") : miss.length && kid && published.includes(kid) ? "the document leans on key " + miss.join(", ") + ", which is NOT published - reject this document" : "key id " + (kid || "?") + " is NOT in the published key directory - reject this document" };
|
|
11
13
|
checks = checks.some((c) => c.id === "key_published") ? checks.map((c) => c.id === "key_published" ? kc : c) : checks.concat([kc]);
|
|
12
14
|
}
|
|
13
15
|
const ran = checks.filter((c) => c.ok !== null), failed = checks.filter((c) => c.ok === false);
|
|
@@ -257,7 +259,7 @@ export class CrawlCheck {
|
|
|
257
259
|
}
|
|
258
260
|
/** Verify a signed document here. With onlineKeys (default), its signing key must also be in the published directory. */
|
|
259
261
|
async verifyDocument(doc, onlineKeys = true) {
|
|
260
|
-
return withTrust(await V.verifyDataDoc(doc), doc?.signature?.kid, onlineKeys ? await this.publishedKeyIds() : null);
|
|
262
|
+
return withTrust(await V.verifyDataDoc(doc), doc?.signature?.kid, onlineKeys ? await this.publishedKeyIds() : null, V.relatedKids(doc));
|
|
261
263
|
}
|
|
262
264
|
/**
|
|
263
265
|
* Preflight, verify the receipt, check it is for THIS operation, and decide. Proceeds on allow; warn only with allowWarn;
|
|
@@ -285,7 +287,7 @@ export class CrawlCheck {
|
|
|
285
287
|
return no(["onlineKeys is false and no publishedKids were pinned: the signer cannot be checked"], receipt);
|
|
286
288
|
let verification;
|
|
287
289
|
try {
|
|
288
|
-
verification = withTrust(await V.verifyDataDoc(receipt), receipt?.signature?.kid, online ? await this.publishedKeyIds() : opts.publishedKids);
|
|
290
|
+
verification = withTrust(await V.verifyDataDoc(receipt), receipt?.signature?.kid, online ? await this.publishedKeyIds() : opts.publishedKids, V.relatedKids(receipt));
|
|
289
291
|
}
|
|
290
292
|
catch (e) {
|
|
291
293
|
return no(["the receipt could not be verified: " + (e?.message || e)].concat(scope.why), receipt);
|
package/dist/mcp.d.ts
CHANGED
|
@@ -30,8 +30,9 @@ export function mcpPreflight(cc: any, opts?: {}): {
|
|
|
30
30
|
/** After initialize, with the tools the server lists. Refuses (and the wrapper closes the client) when they differ from the approved lockfile. */
|
|
31
31
|
afterConnect(tools: any): Promise<import("./index.js").McpLockCheck | null>;
|
|
32
32
|
/** Before tools/call. */
|
|
33
|
-
beforeToolCall(name: any, annotations: any): Promise<{
|
|
34
|
-
receipt:
|
|
33
|
+
beforeToolCall(name: any, annotations: any, tool: any): Promise<{
|
|
34
|
+
receipt: any;
|
|
35
|
+
tool_receipt: any;
|
|
35
36
|
}>;
|
|
36
37
|
};
|
|
37
38
|
/**
|
package/dist/mcp.js
CHANGED
|
@@ -10,10 +10,13 @@
|
|
|
10
10
|
// tools/call: the connect receipt must still be unexpired (else the host is decided again); a tool outside the approved
|
|
11
11
|
// lockfile is refused; a tool annotated destructive (destructiveHint true, or readOnlyHint false with
|
|
12
12
|
// openWorldHint true) asks opts.confirm when opts.confirmDestructive is set.
|
|
13
|
+
// per call: (1.4.0) a tool call gets a receipt of its own, scoped to that tool (name, digest of its definition, opts.scopes),
|
|
14
|
+
// so a connect receipt never stands in for a call to a tool that changed. opts.toolReceipts: "write" (default:
|
|
15
|
+
// every tool not annotated readOnlyHint), "all", or "none".
|
|
13
16
|
// Nothing here calls a tool, reads arguments or alters the request; a refused call throws McpPreflightRefused and the
|
|
14
17
|
// request is never sent.
|
|
15
18
|
import { normalizeDomain } from "./index.js";
|
|
16
|
-
import { checkLock } from "./index.js";
|
|
19
|
+
import { checkLock, toolOperation } from "./index.js";
|
|
17
20
|
export class McpPreflightRefused extends Error {
|
|
18
21
|
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
22
|
}
|
|
@@ -103,7 +106,7 @@ export function mcpPreflight(cc, opts = {}) {
|
|
|
103
106
|
return lc;
|
|
104
107
|
},
|
|
105
108
|
/** Before tools/call. */
|
|
106
|
-
async beforeToolCall(name, annotations) {
|
|
109
|
+
async beforeToolCall(name, annotations, tool) {
|
|
107
110
|
if (state.host && !fresh())
|
|
108
111
|
await decide(state.host);
|
|
109
112
|
if (state.approved && !state.approved.has(name))
|
|
@@ -113,7 +116,16 @@ export function mcpPreflight(cc, opts = {}) {
|
|
|
113
116
|
if (!ok)
|
|
114
117
|
throw new McpPreflightRefused("tools/call", "tool " + name + " is annotated destructive and no human confirmed", { receipt: state.receipt });
|
|
115
118
|
}
|
|
116
|
-
|
|
119
|
+
const mode = opts.toolReceipts || "write", ro = !!(annotations && annotations.readOnlyHint === true);
|
|
120
|
+
let callReceipt = null;
|
|
121
|
+
if (state.host && (mode === "all" || (mode === "write" && !ro))) { // op1/mcp: a receipt for this exact tool call
|
|
122
|
+
const op = tool ? await toolOperation(tool, opts.scopes) : Object.assign({ tool: String(name) }, opts.scopes && opts.scopes.length ? { scopes: opts.scopes.slice() } : {});
|
|
123
|
+
const g = await cc.guard(state.host, "connect", { template, policy: opts.policy, agent: opts.agent, confirm: opts.confirm, allowWarn: !!opts.allowWarn, onlineKeys: opts.onlineKeys, operation: op });
|
|
124
|
+
if (!g.proceed)
|
|
125
|
+
throw new McpPreflightRefused("tools/call", "no receipt for tool " + name + ": " + ((g.why || []).join("; ") || "refused"), { receipt: g.receipt, verification: g.verification });
|
|
126
|
+
callReceipt = g.receipt;
|
|
127
|
+
}
|
|
128
|
+
return { receipt: callReceipt || state.receipt, tool_receipt: callReceipt };
|
|
117
129
|
}
|
|
118
130
|
};
|
|
119
131
|
}
|
|
@@ -129,7 +141,7 @@ export function withPreflight(client, cc, opts = {}) {
|
|
|
129
141
|
client.connect = async function (transport, ...rest) {
|
|
130
142
|
await pf.beforeConnect(opts.serverUrl || transportUrl(transport));
|
|
131
143
|
const r = await connect(transport, ...rest);
|
|
132
|
-
if (opts.lock || opts.confirmDestructive) {
|
|
144
|
+
if (opts.lock || opts.confirmDestructive || (opts.toolReceipts || "write") !== "none") {
|
|
133
145
|
let list = [];
|
|
134
146
|
try {
|
|
135
147
|
const t = typeof client.listTools === "function" ? await client.listTools() : null;
|
|
@@ -140,23 +152,24 @@ export function withPreflight(client, cc, opts = {}) {
|
|
|
140
152
|
}
|
|
141
153
|
list.forEach(function (t) { if (t && t.name)
|
|
142
154
|
tools[t.name] = t; });
|
|
143
|
-
|
|
144
|
-
await pf.afterConnect(list);
|
|
145
|
-
}
|
|
146
|
-
catch (e) {
|
|
155
|
+
if (opts.lock || opts.confirmDestructive)
|
|
147
156
|
try {
|
|
148
|
-
|
|
149
|
-
|
|
157
|
+
await pf.afterConnect(list);
|
|
158
|
+
}
|
|
159
|
+
catch (e) {
|
|
160
|
+
try {
|
|
161
|
+
if (typeof client.close === "function")
|
|
162
|
+
await client.close();
|
|
163
|
+
}
|
|
164
|
+
catch (e2) { }
|
|
165
|
+
throw e;
|
|
150
166
|
}
|
|
151
|
-
catch (e2) { }
|
|
152
|
-
throw e;
|
|
153
|
-
}
|
|
154
167
|
}
|
|
155
168
|
return r;
|
|
156
169
|
};
|
|
157
170
|
client.callTool = async function (params, ...rest) {
|
|
158
171
|
const name = params && params.name;
|
|
159
|
-
await pf.beforeToolCall(name, tools[name] && tools[name].annotations);
|
|
172
|
+
await pf.beforeToolCall(name, tools[name] && tools[name].annotations, tools[name]);
|
|
160
173
|
return callTool(params, ...rest);
|
|
161
174
|
};
|
|
162
175
|
client.preflight = pf;
|
package/dist/verify.d.ts
CHANGED
|
@@ -19,7 +19,8 @@ export function verifyThresholdSigners(x: any): Promise<{
|
|
|
19
19
|
values_match: boolean;
|
|
20
20
|
why: never[];
|
|
21
21
|
}[]>;
|
|
22
|
-
export function
|
|
22
|
+
export function relatedKids(x: any): any[];
|
|
23
|
+
export function trustOf(res: any, kid: any, publishedKids: any, related: any): any;
|
|
23
24
|
export function publishedKeyIds(base: any): Promise<string[]>;
|
|
24
25
|
export function verifyBundle(b: any, opts: any): Promise<any>;
|
|
25
26
|
export function verifyReceipt(rc: any, opts: any): Promise<any>;
|
package/dist/verify.js
CHANGED
|
@@ -654,7 +654,7 @@ async function verifyDataDoc0(x) {
|
|
|
654
654
|
}
|
|
655
655
|
if (x.kind === "crawlcheck-decision") {
|
|
656
656
|
const r = x.resolve || {};
|
|
657
|
-
C("resolve_bound", /^[0-9a-f]{64}$/.test(String(r.sha256 || "")) && r.kid ===
|
|
657
|
+
C("resolve_bound", /^[0-9a-f]{64}$/.test(String(r.sha256 || "")) && typeof r.kid === "string" && r.kid.length > 0, /^[0-9a-f]{64}$/.test(String(r.sha256 || "")) ? "the decision names the resolve answer it was made from (" + String(r.sha256).slice(0, 16) + "…), signed by key " + String(r.kid).slice(0, 12) + "… (online, it must be a published key)" : "the decision does not name a resolve answer digest");
|
|
658
658
|
C("decision_known", ["allow", "warn", "require_confirmation", "block", "unsupported"].indexOf(x.decision) >= 0, "decision is " + x.decision);
|
|
659
659
|
}
|
|
660
660
|
if (x.kind === "crawlcheck-snapshot") {
|
|
@@ -678,7 +678,7 @@ async function verifyDataDoc0(x) {
|
|
|
678
678
|
catch (e) {
|
|
679
679
|
sv = null;
|
|
680
680
|
}
|
|
681
|
-
const same =
|
|
681
|
+
const same = true; // kp3: the log head may be signed by the log key; online, its key must be published (trustOf)
|
|
682
682
|
C("log_head_signature", !!(sv && sv.verified) && t.sth.kind === "crawlcheck-log-sth" && t.sth.batch === t.batch && same, sv && sv.verified ? "batch " + t.batch + "'s signed tree head verifies (tree size " + t.sth.tree_size + ", chained to " + String(t.sth.prev_sth_sha256 || "genesis").slice(0, 16) + "…)" : "the signed tree head does NOT verify");
|
|
683
683
|
}
|
|
684
684
|
else
|
|
@@ -728,13 +728,41 @@ async function verifyDataDoc0(x) {
|
|
|
728
728
|
// Run as a script under ANY file name (a browser saves a second download as "crawlcheck-verify (1).mjs"); imported, it
|
|
729
729
|
// stays a library. The old test matched the file name only, so a renamed copy exited 0 having checked nothing.
|
|
730
730
|
// vt1: integrity, issuer trust and completeness, reported apart. Same semantics as @crawlcheck/sdk and the Python package.
|
|
731
|
-
|
|
731
|
+
// kp3 (2026-10-10): keys a document leans on besides its own signer (the resolve answer a decision names, the log head
|
|
732
|
+
// an answer carries). Since keys are separated by purpose these can differ; online, every one must be published.
|
|
733
|
+
export function relatedKids(x) {
|
|
734
|
+
const out = [];
|
|
735
|
+
try {
|
|
736
|
+
if (x && x.kind === "crawlcheck-decision" && x.resolve && x.resolve.kid)
|
|
737
|
+
out.push(x.resolve.kid);
|
|
738
|
+
const t = x && x.transparency;
|
|
739
|
+
if (t && t.sth && t.sth.signature && t.sth.signature.kid)
|
|
740
|
+
out.push(t.sth.signature.kid);
|
|
741
|
+
if (x && x.kind === "crawlcheck-resolve-as-of") {
|
|
742
|
+
if (x.answer && x.answer.signature && x.answer.signature.kid)
|
|
743
|
+
out.push(x.answer.signature.kid);
|
|
744
|
+
const p = x.log_inclusion;
|
|
745
|
+
if (p && p.sth && p.sth.signature && p.sth.signature.kid)
|
|
746
|
+
out.push(p.sth.signature.kid);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
catch (e) { }
|
|
750
|
+
return out.filter((k, i) => typeof k === "string" && out.indexOf(k) === i);
|
|
751
|
+
}
|
|
752
|
+
export function trustOf(res, kid, publishedKids, related) {
|
|
732
753
|
let checks = Array.isArray(res && res.checks) ? res.checks : [];
|
|
733
754
|
let issuer = null;
|
|
734
755
|
if (Array.isArray(publishedKids)) {
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
756
|
+
const __rel = (related || []).filter((k) => k !== kid), __miss = __rel.filter((k) => publishedKids.indexOf(k) < 0);
|
|
757
|
+
issuer = !!kid && publishedKids.indexOf(kid) > -1 && __miss.length === 0;
|
|
758
|
+
if (!!kid && publishedKids.indexOf(kid) > -1 && __miss.length) {
|
|
759
|
+
const kc0 = { id: "key_published", ok: false, why: "the document is signed by a published key, but it leans on key " + __miss.join(", ") + ", which is NOT published: reject this document" };
|
|
760
|
+
checks = checks.some((c) => c.id === "key_published") ? checks.map((c) => c.id === "key_published" ? kc0 : c) : checks.concat([kc0]);
|
|
761
|
+
}
|
|
762
|
+
else {
|
|
763
|
+
const kc = { id: "key_published", ok: issuer, why: issuer ? "key id " + kid + " is in the published key directory" : !kid ? "this document carries no signature, so its issuer cannot be authenticated" : "key id " + kid + " is NOT in the published key directory: reject this document" };
|
|
764
|
+
checks = checks.some((c) => c.id === "key_published") ? checks.map((c) => c.id === "key_published" ? kc : c) : checks.concat([kc]);
|
|
765
|
+
}
|
|
738
766
|
}
|
|
739
767
|
const ran = checks.filter((c) => c.ok !== null), failed = checks.filter((c) => c.ok === false);
|
|
740
768
|
const unavailable = checks.filter((c) => c.ok === null && c.id !== "key_published").map((c) => c.id);
|
|
@@ -795,7 +823,7 @@ async function verifyDnsTxt0(txt) {
|
|
|
795
823
|
return { record: f, decisions: { read: f.read || null, cite: f.cite || null, connect: f.connect || null, transact: f.transact || null }, checks };
|
|
796
824
|
}
|
|
797
825
|
export async function verifyDnsTxt(txt, opts) { const r = await verifyDnsTxt0(txt); return trustOf(r, r.record && r.record.k, opts && opts.publishedKids); }
|
|
798
|
-
export async function verifyDataDoc(x, opts) { return trustOf(await verifyDataDoc0(x), kidOf(x), opts && opts.publishedKids); }
|
|
826
|
+
export async function verifyDataDoc(x, opts) { return trustOf(await verifyDataDoc0(x), kidOf(x), opts && opts.publishedKids, relatedKids(x)); }
|
|
799
827
|
const isMain = typeof process !== "undefined" && !!(process.argv && process.argv[1]) && await (async () => { try {
|
|
800
828
|
const { pathToFileURL } = await import("node:url");
|
|
801
829
|
const { realpathSync } = await import("node:fs");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crawlcheck/sdk",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.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",
|