@crawlcheck/sdk 1.13.0 → 1.14.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 +13 -0
- package/dist/continuity.d.ts +7 -2
- package/dist/continuity.js +94 -8
- package/dist/index.d.ts +52 -5
- package/dist/index.js +26 -4
- package/dist/jcs.d.ts +37 -0
- package/dist/jcs.js +131 -0
- package/dist/mcp.js +39 -10
- package/examples/receipt-roundtrip.mjs +28 -0
- package/package.json +1 -1
- package/test/fixtures/continuity-gen.mjs +41 -2
- package/test/fixtures/continuity.json +19053 -0
- package/test/fixtures/jcs-v1.json +1603 -0
- package/test/jcs.test.js +54 -0
- package/test/mcp.test.js +13 -0
package/test/jcs.test.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// jcs-v1 (1.14.0): the digests must equal what CrawlCheck's lockfile endpoint signs, for the spec's vectors, for edge
|
|
2
|
+
// cases (empty and absent fields, numbers, UTF-16 key order, unpaired surrogates, depth, size limits, unnamed tools) and
|
|
3
|
+
// for a live lockfile. The golden file is shared with the Python SDK (tests/fixtures/jcs-v1.json).
|
|
4
|
+
import test from "node:test"; import assert from "node:assert/strict"; import fs from "node:fs"; import zlib from "node:zlib";
|
|
5
|
+
import { jcsV1, jcsCanon, jcsContract, lockFromTools, checkLock, continuityCheck, LOCK_V1_MAX_TOOLS, withPreflight } from "../dist/index.js";
|
|
6
|
+
const G = JSON.parse(fs.readFileSync(new URL("./fixtures/jcs-v1.json", import.meta.url), "utf8"));
|
|
7
|
+
const toolsOf = (c) => JSON.parse(c.tools_json ?? zlib.inflateSync(Buffer.from(c.tools_json_deflate_b64, "base64")).toString("utf8"));
|
|
8
|
+
for (const c of G.cases) test("jcs-v1 golden (endpoint's answer): " + c.name, async () => { assert.deepEqual(await jcsV1(toolsOf(c)), c.expect); });
|
|
9
|
+
test("jcs-v1: the live lockfile of crawlcheck.io/mcp", async () => { assert.deepEqual(await jcsV1(G.live.tools), G.live.lock_jcs_v1); });
|
|
10
|
+
test("jcs-v1: the spec's test vectors", async () => {
|
|
11
|
+
const V = G.spec_vectors, t = toolsOf(G.cases.find((c) => c.name === "spec_read_file"));
|
|
12
|
+
assert.equal((await jcsV1([])).tools_sha256, V.empty_list_tools_sha256);
|
|
13
|
+
assert.equal(jcsCanon(jcsContract(t[0])), V.read_file.canonical_contract);
|
|
14
|
+
const r = await jcsV1(t); assert.equal(r.tools[0].contract, V.read_file.contract); assert.equal(r.tools[0].extras, V.read_file.extras); assert.equal(r.tools_sha256, V.read_file.tools_sha256); assert.equal(r.extras_sha256, V.read_file.extras_sha256);
|
|
15
|
+
});
|
|
16
|
+
test("jcs-v1: a missing description, an empty one and a null one are the same contract", async () => {
|
|
17
|
+
const d = (n) => G.cases.find((c) => c.name === n).expect.tools_sha256;
|
|
18
|
+
assert.equal(d("description_absent"), d("description_empty")); assert.equal(d("description_absent"), d("description_null")); assert.equal(d("inputschema_absent"), d("inputschema_empty"));
|
|
19
|
+
assert.notEqual(d("outputschema_empty"), d("inputschema_empty"));
|
|
20
|
+
});
|
|
21
|
+
test("jcs-v1: a partial list is not digested", async () => { assert.equal((await jcsV1([{ name: "a" }], { partial: true })).tools_sha256, null); });
|
|
22
|
+
const many = (n) => Array.from({ length: n }, (_, i) => ({ name: "t" + String(i).padStart(3, "0"), description: "Tool " + i + ".", inputSchema: { type: "object" } }));
|
|
23
|
+
test("lockFromTools: past LOCK_V1_MAX_TOOLS the v1 hash is marked incomplete, never cut silently; jcs_v1 covers every tool", async () => {
|
|
24
|
+
assert.equal(LOCK_V1_MAX_TOOLS, 300);
|
|
25
|
+
const a = await lockFromTools(many(300)); assert.equal(a.complete, true); assert.equal(a.offered, undefined);
|
|
26
|
+
const b = await lockFromTools(many(301)); assert.equal(b.complete, false); assert.equal(b.offered, 301); assert.equal(b.count, 300); assert.equal(b.jcs_v1.count, 301); assert.equal(b.jcs_v1.tools.length, 301);
|
|
27
|
+
const lock = Object.assign({}, b); const c = await checkLock(lock, many(301));
|
|
28
|
+
assert.equal(c.verdict, "incomplete"); assert.equal(c.decision, "block"); assert.equal(c.jcs_v1.contract_unchanged, true);
|
|
29
|
+
});
|
|
30
|
+
test("checkLock reports the jcs-v1 comparison beside the v1 verdict", async () => {
|
|
31
|
+
const t = many(3), lk = await lockFromTools(t); const t2 = JSON.parse(JSON.stringify(t)); t2[1].title = "New title";
|
|
32
|
+
const c = await checkLock(lk, t2); assert.equal(c.verdict, "unchanged"); assert.equal(c.jcs_v1.contract_unchanged, true); assert.equal(c.jcs_v1.extras_unchanged, false);
|
|
33
|
+
});
|
|
34
|
+
test("continuityCheck: a CrawlCheck lockfile's jcs_v1 is what the comparison uses (title change is seen, digest lk-v1 opts out)", async () => {
|
|
35
|
+
const t = many(3), lk = await lockFromTools(t), lock = { tools_sha256: lk.tools_sha256, tools: lk.tools, jcs_v1: lk.jcs_v1 };
|
|
36
|
+
const t2 = JSON.parse(JSON.stringify(t)); t2[0].title = "Renamed";
|
|
37
|
+
const r = await continuityCheck(lock, t2, { now: Date.parse("2026-11-11T00:00:00Z") });
|
|
38
|
+
assert.equal(r.baseline.alg, "jcs-v1"); assert.deepEqual(r.events.map((e) => [e.class, e.tool]), [["tool_changed", "t000"]]); assert.deepEqual(r.paused_tools, ["t000"]);
|
|
39
|
+
const r2 = await continuityCheck(lock, t2, { now: Date.parse("2026-11-11T00:00:00Z"), digest: "lk-v1" }); assert.equal(r2.events[0].class, "unchanged"); assert.equal(r2.baseline.alg, undefined);
|
|
40
|
+
});
|
|
41
|
+
const T = { _url: new URL("https://mcp.example.com/mcp") };
|
|
42
|
+
const cc = { guard: async () => ({ proceed: true, receipt: { decision: "allow" }, verification: {}, why: [] }), preflight: async () => ({}) };
|
|
43
|
+
const client = (pages) => ({ async connect() { }, async close() { }, async callTool() { return {}; }, async listTools(p) { const i = Number((p && p.cursor) || 0); return { tools: pages[i], nextCursor: i + 1 < pages.length ? String(i + 1) : undefined }; } });
|
|
44
|
+
test("withPreflight reads every page of tools/list (nextCursor followed)", async () => {
|
|
45
|
+
const t = many(5), lk = await lockFromTools(t), lock = { tools_sha256: lk.tools_sha256, tools: lk.tools };
|
|
46
|
+
const c = withPreflight(client([t.slice(0, 2), t.slice(2, 4), t.slice(4)]), cc, { lock, toolReceipts: "none", publishedKids: [], onlineKeys: false, local: "allow" });
|
|
47
|
+
await c.connect(T); assert.equal(c.preflight.state.lockCheck.verdict, "unchanged");
|
|
48
|
+
});
|
|
49
|
+
test("withPreflight with a v1 lockfile refuses a list it could not read in full", async () => {
|
|
50
|
+
const t = many(2), lk = await lockFromTools(t), lock = { tools_sha256: lk.tools_sha256, tools: lk.tools };
|
|
51
|
+
const loop = { async connect() { }, async close() { }, async callTool() { return {}; }, async listTools() { return { tools: t, nextCursor: "same" }; } };
|
|
52
|
+
const c = withPreflight(loop, cc, { lock, toolReceipts: "none", onlineKeys: false, local: "allow" });
|
|
53
|
+
await assert.rejects(() => c.connect(T), /could not be read in full/);
|
|
54
|
+
});
|
package/test/mcp.test.js
CHANGED
|
@@ -106,3 +106,16 @@ test("1.4.0: a refused tool receipt stops tools/call; toolReceipts none skips it
|
|
|
106
106
|
await c2.connect(T("https://good.example/mcp")); await c2.callTool({ name: "delete_repo" });
|
|
107
107
|
assert.equal(k2.calls.filter((x) => x.tool).length, 0);
|
|
108
108
|
});
|
|
109
|
+
|
|
110
|
+
test("a denied refresh cannot authorize a subsequent tool call", async () => {
|
|
111
|
+
const policies = { "good.example": "allow" };
|
|
112
|
+
const k = cc(policies);
|
|
113
|
+
const client = withPreflight(mcpClient(TOOLS), k, { toolReceipts: "none" });
|
|
114
|
+
await client.connect(T("https://good.example/mcp"));
|
|
115
|
+
client.preflight.state.receipt.expires_at = new Date(0).toISOString();
|
|
116
|
+
policies["good.example"] = "block";
|
|
117
|
+
await assert.rejects(() => client.callTool({ name: "search" }), McpPreflightRefused);
|
|
118
|
+
await assert.rejects(() => client.callTool({ name: "search" }), McpPreflightRefused);
|
|
119
|
+
assert.deepEqual(client.sent, ["initialize https://good.example/mcp"]);
|
|
120
|
+
assert.equal(k.calls.length, 3);
|
|
121
|
+
});
|