@crawlcheck/sdk 1.0.8 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,54 @@
1
+ // The guard() acceptance suite (https://crawlcheck.io/spec/guard) against this SDK.
2
+ // CC_SDK=<path or package name> runs it against another build (CI uses the copy installed from npm).
3
+ import { test } from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import { readFileSync, existsSync } from "node:fs";
6
+ const here = new URL(".", import.meta.url);
7
+ const CASES = process.env.CC_GUARD_CASES || [new URL("../../spec/guard-acceptance/cases.json", here), new URL("./fixtures/guard-acceptance.json", here)].find((u) => existsSync(u));
8
+ const suite = JSON.parse(readFileSync(CASES, "utf8"));
9
+ const { CrawlCheck } = await import(process.env.CC_SDK || "../dist/index.js");
10
+ const BASE = "https://cc.test";
11
+
12
+ function fakeFetch(c, log) {
13
+ return async (url, init = {}) => {
14
+ url = String(url);
15
+ if (url.startsWith(BASE)) {
16
+ const p = new URL(url).pathname, cc = c.crawlcheck;
17
+ if (cc.unreachable) throw new TypeError("fetch failed");
18
+ if (p === "/.well-known/http-message-signatures-directory") return new Response(JSON.stringify(suite.directory), { headers: { "content-type": "application/json" } });
19
+ if (p === "/api/v1/preflight") {
20
+ const b = JSON.parse(init.body || "{}"); log.asked.push(b.domain);
21
+ if (cc.by_domain) { const r = cc.by_domain[b.domain]; return r ? new Response(JSON.stringify(r), { headers: { "content-type": "application/json" } }) : new Response("{\"error\":\"none\"}", { status: 404 }); }
22
+ return new Response(cc.text !== undefined ? cc.text : JSON.stringify(cc.body), { status: cc.status || 200, headers: { "content-type": "application/json" } });
23
+ }
24
+ return new Response("{}", { status: 404 });
25
+ }
26
+ log.fetched.push(url);
27
+ const h = new Headers(init.headers || {}); if (h.get("crawlcheck-receipt")) log.header_to.push(new URL(url).hostname);
28
+ const s = (c.sites || {})[url];
29
+ if (!s) return new Response("not in fixture", { status: 404 });
30
+ return new Response(s.body ?? null, { status: s.status, headers: s.location ? { location: s.location } : {} });
31
+ };
32
+ }
33
+
34
+ for (const c of suite.cases) {
35
+ test(c.id + ": " + c.title, async () => {
36
+ const log = { fetched: [], header_to: [], asked: [] };
37
+ const cc = new CrawlCheck({ base: BASE, fetch: fakeFetch(c, log) });
38
+ const q = c.request, opts = { nonce: q.nonce, now: Date.parse(q.now), allowWarn: q.allow_warn };
39
+ if (q.confirm !== null) opts.confirm = async () => q.confirm;
40
+ if (q.url) {
41
+ assert.equal(typeof cc.guardedFetch, "function", "this SDK has no guarded fetch, so nothing re-checks redirects");
42
+ const r = await cc.guardedFetch(q.url, Object.assign(opts, { action: q.action, maxHops: q.max_hops }));
43
+ assert.equal(r.proceed, c.expect.proceed, "proceed; why: " + r.why.join(" | "));
44
+ if (c.expect.fetched) assert.deepEqual(log.fetched, c.expect.fetched);
45
+ if (c.expect.fetched_count !== undefined) assert.equal(log.fetched.length, c.expect.fetched_count);
46
+ if (c.expect.receipt_header_to) assert.deepEqual(log.header_to, c.expect.receipt_header_to);
47
+ } else {
48
+ let r; try { r = await cc.guard(q.domain, q.action, opts); } catch (e) { r = { proceed: false, header: null, threw: String(e && e.message || e) }; } // a throw is a refusal: the caller cannot act on it
49
+ assert.equal(r.proceed, c.expect.proceed, "proceed; why: " + (r.threw || (r.why || []).join(" | ")));
50
+ assert.equal(!!r.header, c.expect.proceed, "the receipt header is given only when it proceeds");
51
+ if (!r.proceed) assert.ok(r.threw || (r.why && r.why.length > 0), "a refusal says why");
52
+ }
53
+ });
54
+ }
@@ -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
+ });