@crawlcheck/sdk 1.15.0 → 1.15.2

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
@@ -1136,3 +1136,5 @@ export interface LangChainToolsModule {
1136
1136
  * Same three tools as agentTools(); each returns its result as a JSON string; they fail closed. */
1137
1137
  export declare function langchainTools(cc: CrawlCheck, lc: LangChainToolsModule, opts?: AgentToolOptions): unknown[];
1138
1138
  export default CrawlCheck;
1139
+ /** Headers guardedFetch still sends after a redirect leaves the origin the caller named. Everything else stays behind. */
1140
+ export declare const GUARDED_SAFE_HEADERS: string[];
package/dist/index.js CHANGED
@@ -495,7 +495,8 @@ export class CrawlCheck {
495
495
  const action = opts.action ?? "read", max = opts.maxHops ?? 10, f = opts.fetch ? opts.fetch.bind(globalThis) : this.f;
496
496
  const init = Object.assign({}, opts.init || {}), method = String(init.method || "GET").toUpperCase();
497
497
  const hops = [], seen = new Map();
498
- let cur = url;
498
+ let cur = url, origin0 = "";
499
+ try { origin0 = new URL(url).origin; } catch { origin0 = ""; }
499
500
  for (let n = 0; n <= max; n++) {
500
501
  let u;
501
502
  try {
@@ -519,7 +520,10 @@ export class CrawlCheck {
519
520
  hops.push(hop);
520
521
  if (!g.proceed)
521
522
  return { proceed: false, response: null, url: cur, hops, why: [dom + ": " + (g.why.join("; ") || "refused")] };
522
- const h = new Headers(init.headers || {});
523
+ // hdr1 (1.15.2): caller headers stay on the origin the caller named; elsewhere only the allowlist travels
524
+ const sameOrigin = u.origin === origin0;
525
+ const h = new Headers();
526
+ new Headers(init.headers || {}).forEach((v, k) => { if (sameOrigin || GUARDED_SAFE_HEADERS.includes(k.toLowerCase())) h.set(k, v); });
523
527
  if (g.header)
524
528
  for (const [k, v] of Object.entries(g.header))
525
529
  h.set(k, v);
@@ -791,3 +795,5 @@ export function langchainTools(cc, lc, opts = {}) {
791
795
  return agentTools(cc, opts).map((t) => lc.tool(async (input) => JSON.stringify(await t.execute(input ?? {})), { name: t.name, description: t.description, schema: t.parameters }));
792
796
  }
793
797
  export default CrawlCheck;
798
+ /** Headers guardedFetch still sends after a redirect leaves the origin the caller named. */
799
+ export const GUARDED_SAFE_HEADERS = ["accept", "accept-language", "accept-encoding", "user-agent", "cache-control", "pragma"];
package/dist/tsd.js CHANGED
@@ -75,6 +75,9 @@ export async function toolSetDigest(tools, opts = {}) {
75
75
  const out = { spec: TSD_SPEC, contract: null, extras: null };
76
76
  if (opts.partial) { out.contract = { error: "TSD-PAGE" }; out.extras = { error: "TSD-PAGE" }; return out; }
77
77
  if (!Array.isArray(tools)) { out.contract = { error: "TSD-TOOL" }; out.extras = { error: "TSD-TOOL" }; return out; }
78
+ // Section 10: a set-level fault (TSD-TOOL) is found before any value is written, so it wins over TSD-TEXT / TSD-NUMBER in an earlier entry.
79
+ try { for (const item of tools) toolObject(item); }
80
+ catch (e) { if (!(e instanceof ToolSetDigestError)) throw e; const r = e.tool ? { error: e.code, tool: e.tool } : { error: e.code }; out.contract = r; out.extras = Object.assign({}, r); return out; }
78
81
  for (const [kind, fn] of [["contract", contractOf], ["extras", extrasDigestOf]]) {
79
82
  try { out[kind] = await fn(tools); }
80
83
  catch (e) { if (!(e instanceof ToolSetDigestError)) throw e; out[kind] = e.tool ? { error: e.code, tool: e.tool } : { error: e.code }; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crawlcheck/sdk",
3
- "version": "1.15.0",
3
+ "version": "1.15.2",
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",
@@ -2,7 +2,7 @@
2
2
  "format": "mcp-tool-set-digest/1 test vectors",
3
3
  "specification": "../SPEC.md",
4
4
  "licence": "Apache-2.0 OR MIT",
5
- "how": "tools is JSON text. Parse it and compute the contract digest (SPEC.md sections 4 to 7) and the extras digest (section 8). Each of contract and extras is {digest, hex, canonical}, where digest is the SRI form, hex the same hash as sha256:<hex> and canonical the text that is hashed; or {error} with the code of section 10; extras is {digest: null} when no tool carries a member outside the contract. equal and differ name pairs of vectors whose digests of the named kind must match or must not.",
5
+ "how": "tools is JSON text. Parse it and compute the contract digest (SPEC.md sections 4 to 7) and the extras digest (section 8). Each of contract and extras is {digest, hex, canonical}, where digest is the SRI form, hex the same hash as sha256:<hex> and canonical the text that is hashed; or {error} with the code of section 10 (where several codes apply, the one section 10 says to report); extras is {digest: null} when no tool carries a member outside the contract. equal and differ name pairs of vectors whose digests of the named kind must match or must not.",
6
6
  "vectors": [
7
7
  {
8
8
  "id": "empty",
@@ -702,6 +702,36 @@
702
702
  "canonical": "{\"tools\":{\"read_file\":{\"annotations\":{\"readOnlyHint\":true},\"title\":\"Read file\"}}}"
703
703
  }
704
704
  },
705
+ {
706
+ "id": "annotation-read-only-true",
707
+ "note": "A tool declares readOnlyHint true.",
708
+ "tools": "[{\"name\":\"read_file\",\"description\":\"Read a file.\",\"inputSchema\":{\"type\":\"object\"},\"annotations\":{\"readOnlyHint\":true}}]",
709
+ "contract": {
710
+ "digest": "sha256-EElRjaxCDoKPx+5Pggp+uMlPBOZmDDmKR+jzQQr0WHI=",
711
+ "hex": "sha256:1049518dac420e828fc7ee4f820a7eb8c94f04e6660c398a47e8f3410af45872",
712
+ "canonical": "[{\"description\":\"Read a file.\",\"inputSchema\":{\"type\":\"object\"},\"name\":\"read_file\"}]"
713
+ },
714
+ "extras": {
715
+ "digest": "sha256-FVPy/LHwESgNQH8PzbY0n/dTpoAdDJ0krCgoTwQrbKY=",
716
+ "hex": "sha256:1553f2fcb1f011280d407f0fcdb6349ff753a6801d0c9d24ac28284f042b6ca6",
717
+ "canonical": "{\"tools\":{\"read_file\":{\"annotations\":{\"readOnlyHint\":true}}}}"
718
+ }
719
+ },
720
+ {
721
+ "id": "annotation-read-only-false",
722
+ "note": "Only readOnlyHint changes to false: the contract digest stays equal and the extras digest changes.",
723
+ "tools": "[{\"name\":\"read_file\",\"description\":\"Read a file.\",\"inputSchema\":{\"type\":\"object\"},\"annotations\":{\"readOnlyHint\":false}}]",
724
+ "contract": {
725
+ "digest": "sha256-EElRjaxCDoKPx+5Pggp+uMlPBOZmDDmKR+jzQQr0WHI=",
726
+ "hex": "sha256:1049518dac420e828fc7ee4f820a7eb8c94f04e6660c398a47e8f3410af45872",
727
+ "canonical": "[{\"description\":\"Read a file.\",\"inputSchema\":{\"type\":\"object\"},\"name\":\"read_file\"}]"
728
+ },
729
+ "extras": {
730
+ "digest": "sha256-p9clya9gL1QRbgrZodGLEAIPJIQUO5ZkgO6V4uuksE8=",
731
+ "hex": "sha256:a7d725c9af602f54116e0ad9a1d18b10020f2484143b966480ee95e2eba4b04f",
732
+ "canonical": "{\"tools\":{\"read_file\":{\"annotations\":{\"readOnlyHint\":false}}}}"
733
+ }
734
+ },
705
735
  {
706
736
  "id": "extras-numbers-as-written",
707
737
  "note": "Numbers in extras, in forms JSON allows.",
@@ -803,6 +833,32 @@
803
833
  "error": "TSD-NAME"
804
834
  }
805
835
  },
836
+ {
837
+ "id": "duplicate-names-before-lone-surrogate",
838
+ "note": "Two tools share a name, both carry extras, and the first listed holds a lone surrogate in them: TSD-NAME and TSD-TEXT both apply, and section 10 reports TSD-NAME.",
839
+ "tools": "[{\"name\":\"search\",\"description\":\"Search the web.\",\"title\":\"Web \\ud800 search\"},{\"name\":\"search\",\"description\":\"Search the docs.\",\"title\":\"Docs search\"}]",
840
+ "contract": {
841
+ "digest": "sha256-+KFZbT1NAvgwjx+ZGq/rkpRMzXZRftl4baxdoEbNKEk=",
842
+ "hex": "sha256:f8a1596d3d4d02f8308f1f991aafeb92944ccd76517ed9786dac5da046cd2849",
843
+ "canonical": "[{\"description\":\"Search the docs.\",\"inputSchema\":{},\"name\":\"search\"},{\"description\":\"Search the web.\",\"inputSchema\":{},\"name\":\"search\"}]"
844
+ },
845
+ "extras": {
846
+ "error": "TSD-NAME"
847
+ }
848
+ },
849
+ {
850
+ "id": "duplicate-names-before-number-overflow",
851
+ "note": "The same with a number that has no double in the first one's extras: TSD-NAME and TSD-NUMBER both apply, and section 10 reports TSD-NAME.",
852
+ "tools": "[{\"name\":\"search\",\"description\":\"Search the web.\",\"_meta\":{\"limit\":1e400}},{\"name\":\"search\",\"description\":\"Search the docs.\",\"title\":\"Docs search\"}]",
853
+ "contract": {
854
+ "digest": "sha256-+KFZbT1NAvgwjx+ZGq/rkpRMzXZRftl4baxdoEbNKEk=",
855
+ "hex": "sha256:f8a1596d3d4d02f8308f1f991aafeb92944ccd76517ed9786dac5da046cd2849",
856
+ "canonical": "[{\"description\":\"Search the docs.\",\"inputSchema\":{},\"name\":\"search\"},{\"description\":\"Search the web.\",\"inputSchema\":{},\"name\":\"search\"}]"
857
+ },
858
+ "extras": {
859
+ "error": "TSD-NAME"
860
+ }
861
+ },
806
862
  {
807
863
  "id": "tool-is-string",
808
864
  "note": "An entry that is not an object.",
@@ -879,6 +935,17 @@
879
935
  "extras": {
880
936
  "error": "TSD-TOOL"
881
937
  }
938
+ },
939
+ {
940
+ "id": "lone-surrogate-before-bad-entry",
941
+ "note": "A lone surrogate in the first tool and an entry with an empty name after it: TSD-TOOL and TSD-TEXT both apply, and section 10 reports TSD-TOOL for both digests.",
942
+ "tools": "[{\"name\":\"read\",\"description\":\"x\\ud800y\"},{\"name\":\"\"}]",
943
+ "contract": {
944
+ "error": "TSD-TOOL"
945
+ },
946
+ "extras": {
947
+ "error": "TSD-TOOL"
948
+ }
882
949
  }
883
950
  ],
884
951
  "equal": [
@@ -973,6 +1040,13 @@
973
1040
  ],
974
1041
  "digest": "extras"
975
1042
  },
1043
+ {
1044
+ "ids": [
1045
+ "annotation-read-only-true",
1046
+ "annotation-read-only-false"
1047
+ ],
1048
+ "digest": "contract"
1049
+ },
976
1050
  {
977
1051
  "ids": [
978
1052
  "extras-numbers-as-written",
@@ -1059,6 +1133,13 @@
1059
1133
  ],
1060
1134
  "digest": "contract"
1061
1135
  },
1136
+ {
1137
+ "ids": [
1138
+ "annotation-read-only-true",
1139
+ "annotation-read-only-false"
1140
+ ],
1141
+ "digest": "extras"
1142
+ },
1062
1143
  {
1063
1144
  "ids": [
1064
1145
  "extras-present",
@@ -0,0 +1,32 @@
1
+ // hdr1: caller headers stay on the origin the caller named; after a redirect to another origin only the allowlist travels.
2
+ import test from "node:test"; import assert from "node:assert/strict";
3
+ import { CrawlCheck, GUARDED_SAFE_HEADERS } from "../dist/index.js";
4
+ function client() { const cc = new CrawlCheck(); cc.guard = async () => ({ proceed: true, why: [], receipt: { decision: "allow" }, header: { "crawlcheck-receipt": "r" } }); return cc; }
5
+ function recorder(map) { const seen = []; const f = async (u, init) => { seen.push({ url: String(u), headers: Object.fromEntries(new Headers(init.headers).entries()) }); const to = map[String(u)]; return to ? new Response(null, { status: 302, headers: { location: to } }) : new Response("ok", { status: 200 }); }; return { f, seen }; }
6
+ const H = { authorization: "Bearer secret", cookie: "sid=1", "x-api-key": "k", accept: "text/html", "user-agent": "t/1" };
7
+ test("another host: Authorization, Cookie and custom headers are dropped; the allowlist and the receipt stay", async () => {
8
+ const { f, seen } = recorder({ "https://a.example/x": "https://b.example/y" });
9
+ const r = await client().guardedFetch("https://a.example/x", { init: { headers: H }, fetch: f });
10
+ assert.equal(r.proceed, true); assert.equal(seen.length, 2);
11
+ assert.equal(seen[0].headers.authorization, "Bearer secret"); assert.equal(seen[0].headers.cookie, "sid=1");
12
+ for (const k of ["authorization", "cookie", "x-api-key"]) assert.equal(seen[1].headers[k], undefined, k + " crossed origins");
13
+ assert.equal(seen[1].headers.accept, "text/html"); assert.equal(seen[1].headers["user-agent"], "t/1"); assert.equal(seen[1].headers["crawlcheck-receipt"], "r");
14
+ });
15
+ test("same origin: every caller header is kept", async () => {
16
+ const { f, seen } = recorder({ "https://a.example/x": "https://a.example/y" });
17
+ await client().guardedFetch("https://a.example/x", { init: { headers: H }, fetch: f });
18
+ assert.equal(seen[1].headers.authorization, "Bearer secret"); assert.equal(seen[1].headers["x-api-key"], "k");
19
+ });
20
+ test("scheme downgrade and port change count as another origin", async () => {
21
+ for (const to of ["http://a.example/y", "https://a.example:8443/y"]) {
22
+ const { f, seen } = recorder({ "https://a.example/x": to });
23
+ await client().guardedFetch("https://a.example/x", { init: { headers: H }, fetch: f });
24
+ assert.equal(seen[1].headers.authorization, undefined, to); assert.equal(seen[1].headers.cookie, undefined, to);
25
+ }
26
+ });
27
+ test("back to the first origin: headers return", async () => {
28
+ const { f, seen } = recorder({ "https://a.example/x": "https://b.example/y", "https://b.example/y": "https://a.example/z" });
29
+ await client().guardedFetch("https://a.example/x", { init: { headers: H }, fetch: f });
30
+ assert.equal(seen[1].headers.authorization, undefined); assert.equal(seen[2].headers.authorization, "Bearer secret");
31
+ });
32
+ test("the allowlist holds no credential header", () => { for (const k of ["authorization", "cookie", "proxy-authorization"]) assert.equal(GUARDED_SAFE_HEADERS.includes(k), false); });
package/test/tsd.test.js CHANGED
@@ -1,4 +1,4 @@
1
- // mcp-tool-set-digest/1: the 68 published vectors (github.com/alexar76/mcp-tool-set-digest, Apache-2.0 OR MIT), every
1
+ // mcp-tool-set-digest/1: the 73 published vectors (github.com/alexar76/mcp-tool-set-digest, Apache-2.0 OR MIT), every
2
2
  // canonical text they carry, and the equal / differ pairs. Then the relation to jcs-v1: equal in the ordinary case.
3
3
  import { test } from "node:test";
4
4
  import assert from "node:assert/strict";
@@ -7,7 +7,7 @@ import { toolSetDigest, tsdCanonical, ToolSetDigestError, jcsV1, TSD_SPEC } from
7
7
  const V = JSON.parse(fs.readFileSync(new URL("./fixtures/tool-set-digest-vectors.json", import.meta.url), "utf8"));
8
8
  const key = (side) => (side.error ? "E:" + side.error : String(side.digest));
9
9
  const got = {};
10
- test("the fixture is the published set", () => { assert.equal(V.vectors.length, 68); assert.equal(TSD_SPEC, "mcp-tool-set-digest/1"); });
10
+ test("the fixture is the published set", () => { assert.equal(V.vectors.length, 73); assert.equal(TSD_SPEC, "mcp-tool-set-digest/1"); });
11
11
  for (const v of V.vectors) test("vector " + v.id, async () => {
12
12
  const r = (got[v.id] = await toolSetDigest(JSON.parse(v.tools)));
13
13
  for (const k of ["contract", "extras"]) {