@crawlcheck/sdk 1.11.0 → 1.13.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 +45 -0
- package/dist/continuity.d.ts +44 -0
- package/dist/continuity.js +136 -6
- package/dist/frameworks.d.ts +62 -0
- package/dist/frameworks.js +267 -0
- package/dist/index.d.ts +125 -5
- package/dist/index.js +18 -0
- package/dist/mcp.js +36 -3
- package/package.json +1 -1
- package/test/baseline.test.js +93 -0
- package/test/fixtures/continuity-gen.mjs +54 -4
- package/test/fixtures/continuity.json +5451 -131
- package/test/fixtures/key-directory-20261107.json +1 -0
- package/test/fixtures/lock-signed-noparts.json +1 -0
- package/test/fixtures/lock-signed.json +1 -0
- package/test/fixtures/stub-mcp-server.mjs +19 -0
- package/test/frameworks.test.js +118 -0
package/dist/index.d.ts
CHANGED
|
@@ -63,7 +63,8 @@ export interface ContinuityRecord {
|
|
|
63
63
|
approved_at: string | null;
|
|
64
64
|
age_days: number | null;
|
|
65
65
|
server: string | null;
|
|
66
|
-
per_part: boolean;
|
|
66
|
+
per_part: boolean; /** 1.13.0: verified | operator_signed | unsigned | invalid (null when the caller did not check). */
|
|
67
|
+
trust: BaselineTrust["trust"] | null;
|
|
67
68
|
} | null;
|
|
68
69
|
observed: {
|
|
69
70
|
tools_sha256: string;
|
|
@@ -87,7 +88,8 @@ export interface ContinuityOptions {
|
|
|
87
88
|
serverIdentity?: string;
|
|
88
89
|
expectPolicySha256?: string;
|
|
89
90
|
now?: number;
|
|
90
|
-
detail?: string;
|
|
91
|
+
detail?: string; /** 1.13.0: the result of verifyLockBaseline, named in record.baseline.trust. */
|
|
92
|
+
baselineTrust?: BaselineTrust["trust"];
|
|
91
93
|
}
|
|
92
94
|
export declare const CONTINUITY_DEFAULT_POLICY: {
|
|
93
95
|
v: 1;
|
|
@@ -126,6 +128,122 @@ export declare const acceptanceOf: (receipt: unknown, verification: unknown, sco
|
|
|
126
128
|
confirmed?: boolean | null;
|
|
127
129
|
lock?: unknown;
|
|
128
130
|
}) => Acceptance;
|
|
131
|
+
/** An Ed25519 public key ({kty: "OKP", crv: "Ed25519", x}) or its x. */
|
|
132
|
+
export type OperatorKey = {
|
|
133
|
+
kty?: "OKP";
|
|
134
|
+
crv?: "Ed25519";
|
|
135
|
+
x: string;
|
|
136
|
+
} | string;
|
|
137
|
+
export interface BaselineOptions {
|
|
138
|
+
/** The operator's Ed25519 public keys; a lockfile signed with signLock by one of them is operator_signed. */
|
|
139
|
+
operatorKeys?: OperatorKey[];
|
|
140
|
+
/** keyPolicyFrom(directory) / publishedKeyPolicy(): CrawlCheck's keys with purposes and rotation, instead of the pinned ids. */
|
|
141
|
+
keyPolicy?: unknown;
|
|
142
|
+
/** CrawlCheck key ids to accept instead of the pinned CRAWLCHECK_LOCK_KIDS (publishedKeyIds()). */
|
|
143
|
+
publishedKids?: string[];
|
|
144
|
+
trustedKids?: string[];
|
|
145
|
+
/** Refuse an unsigned lockfile (default: true when operatorKeys are given). */
|
|
146
|
+
requireSignedBaseline?: boolean;
|
|
147
|
+
}
|
|
148
|
+
export interface BaselineTrust {
|
|
149
|
+
trust: "verified" | "operator_signed" | "unsigned" | "invalid";
|
|
150
|
+
signer: "crawlcheck" | "operator" | null;
|
|
151
|
+
kid: string | null;
|
|
152
|
+
why: string;
|
|
153
|
+
parts_used: boolean;
|
|
154
|
+
}
|
|
155
|
+
export declare const CRAWLCHECK_LOCK_KIDS: readonly string[];
|
|
156
|
+
/** Is this the lockfile CrawlCheck or the operator signed? Offline. lock is the baseline to compare against (unsigned parts removed). */
|
|
157
|
+
export declare const verifyLockBaseline: (lock: unknown, opts?: BaselineOptions) => Promise<BaselineTrust & {
|
|
158
|
+
lock: McpLock;
|
|
159
|
+
}>;
|
|
160
|
+
/** Sign a lockfile you approved (or edited) with your own Ed25519 key, over its canonical JSON. Returns a copy with operator_signature. */
|
|
161
|
+
export declare const signLock: <T extends McpLock>(lock: T, privateKey: {
|
|
162
|
+
kty?: "OKP";
|
|
163
|
+
crv?: "Ed25519";
|
|
164
|
+
x: string;
|
|
165
|
+
d: string;
|
|
166
|
+
}) => Promise<T & {
|
|
167
|
+
operator_signature: {
|
|
168
|
+
v: 1;
|
|
169
|
+
alg: "Ed25519";
|
|
170
|
+
kid: string;
|
|
171
|
+
message: string;
|
|
172
|
+
sig: string;
|
|
173
|
+
};
|
|
174
|
+
}>;
|
|
175
|
+
/** A new operator key: keep privateKey away from the agent; pass publicKey in operatorKeys. */
|
|
176
|
+
export declare const newOperatorKey: () => Promise<{
|
|
177
|
+
privateKey: {
|
|
178
|
+
kty: "OKP";
|
|
179
|
+
crv: "Ed25519";
|
|
180
|
+
x: string;
|
|
181
|
+
d: string;
|
|
182
|
+
};
|
|
183
|
+
publicKey: {
|
|
184
|
+
kty: "OKP";
|
|
185
|
+
crv: "Ed25519";
|
|
186
|
+
x: string;
|
|
187
|
+
};
|
|
188
|
+
kid: string;
|
|
189
|
+
}>;
|
|
190
|
+
export interface ContinuityGateOptions extends ContinuityOptions, BaselineOptions {
|
|
191
|
+
/** A person's re-approval: { continuity } for the server, { tool, continuity } for one tool. Remembered per definition. */
|
|
192
|
+
confirm?: (request: {
|
|
193
|
+
tool?: string;
|
|
194
|
+
continuity: ContinuityRecord;
|
|
195
|
+
}) => boolean | Promise<boolean>;
|
|
196
|
+
onRecord?: (record: ContinuityRecord) => unknown;
|
|
197
|
+
/** false: no console.warn on a change. */ log?: boolean;
|
|
198
|
+
logger?: {
|
|
199
|
+
warn: (msg: string) => unknown;
|
|
200
|
+
};
|
|
201
|
+
/** Records kept in gate.records (default 50). */ keep?: number;
|
|
202
|
+
}
|
|
203
|
+
export interface ContinuityGate {
|
|
204
|
+
mode: "observe" | "review" | "enforce";
|
|
205
|
+
records: ContinuityRecord[];
|
|
206
|
+
record: ContinuityRecord | null;
|
|
207
|
+
paused: Set<string>; /** 1.13.0: the startup check of the lockfile's signature. */
|
|
208
|
+
ready: Promise<BaselineTrust>;
|
|
209
|
+
baseline: BaselineTrust | null;
|
|
210
|
+
readonly refused: Error | null;
|
|
211
|
+
check(tools: McpTool[], meta?: {
|
|
212
|
+
complete?: boolean;
|
|
213
|
+
}): Promise<ContinuityRecord>;
|
|
214
|
+
fail(cls: string, detail?: string): Promise<ContinuityRecord>;
|
|
215
|
+
allowed<T>(tools: T[], nameOf?: (t: T) => string): T[];
|
|
216
|
+
callOk(name: string): true;
|
|
217
|
+
}
|
|
218
|
+
/** Thrown in review/enforce when a record's outcome is pause, refresh or deny and nobody re-approved (or a paused tool is called). */
|
|
219
|
+
export declare const ContinuityRefused: new (why: string, record?: ContinuityRecord | null, tool?: string | null) => Error & {
|
|
220
|
+
why: string;
|
|
221
|
+
record: ContinuityRecord | null;
|
|
222
|
+
tool: string | null;
|
|
223
|
+
};
|
|
224
|
+
/** The shared gate behind the framework adapters. Default mode observe. */
|
|
225
|
+
export declare const continuityGate: (lock: McpLock, opts?: ContinuityGateOptions) => ContinuityGate;
|
|
226
|
+
/** 1.13.0: continuityGate that fails at startup: throws ContinuityRefused when the baseline is untrusted (outside observe). */
|
|
227
|
+
export declare const openContinuityGate: (lock: McpLock, opts?: ContinuityGateOptions) => Promise<ContinuityGate>;
|
|
228
|
+
/** @openai/agents: wrap an MCP server so every listTools() is checked against lock. Records at server.continuity. */
|
|
229
|
+
export declare const withContinuity: <S extends {
|
|
230
|
+
listTools: (...a: any[]) => Promise<any[]>;
|
|
231
|
+
callTool: (...a: any[]) => Promise<any>;
|
|
232
|
+
}>(server: S, lock: McpLock, opts?: ContinuityGateOptions & {
|
|
233
|
+
serverUrl?: string;
|
|
234
|
+
gate?: ContinuityGate;
|
|
235
|
+
}) => S & {
|
|
236
|
+
continuity: ContinuityGate;
|
|
237
|
+
};
|
|
238
|
+
/** @langchain/mcp-adapters: loadMcpTools with the check. client: a MultiServerMCPClient or a connected MCP SDK Client. */
|
|
239
|
+
export declare const loadMcpToolsWithContinuity: (serverName: string, client: unknown, lock: McpLock, opts?: ContinuityGateOptions & {
|
|
240
|
+
serverUrl?: string;
|
|
241
|
+
gate?: ContinuityGate;
|
|
242
|
+
adapters?: unknown;
|
|
243
|
+
loadOptions?: unknown;
|
|
244
|
+
}) => Promise<any[] & {
|
|
245
|
+
continuity: ContinuityGate;
|
|
246
|
+
}>;
|
|
129
247
|
/** One verifier check. ok: true passed, false FAILED, null could not run (why says why) - never a pass. */
|
|
130
248
|
export interface VerifyCheck {
|
|
131
249
|
id: string;
|
|
@@ -655,7 +773,7 @@ export declare const applyRevocations: (store: RevocationStore, doc: unknown, no
|
|
|
655
773
|
export declare const revokedFor: (store: RevocationStore, domain: string, action?: string) => (RevokedEntry & {
|
|
656
774
|
domain: string;
|
|
657
775
|
}) | null;
|
|
658
|
-
export interface McpPreflightOptions extends PreflightOptions {
|
|
776
|
+
export interface McpPreflightOptions extends PreflightOptions, BaselineOptions {
|
|
659
777
|
/** Proceed on warn too (default false). */
|
|
660
778
|
allowWarn?: boolean;
|
|
661
779
|
/** Called on require_confirmation (and, with confirmDestructive, before a destructive tool call); the connect or call proceeds only if it resolves true. */
|
|
@@ -674,7 +792,7 @@ export interface McpPreflightOptions extends PreflightOptions {
|
|
|
674
792
|
/** The server URL when the transport does not expose it. */
|
|
675
793
|
serverUrl?: string;
|
|
676
794
|
/** 1.11.0: tool-interface continuity with lock. mode observe (record only), review (changes go to confirm), enforce (the policy applies). Every check is kept in state.records and passed to onRecord. https://crawlcheck.io/spec/continuity */
|
|
677
|
-
continuity?: ContinuityOptions & {
|
|
795
|
+
continuity?: ContinuityOptions & BaselineOptions & {
|
|
678
796
|
onRecord?: (record: ContinuityRecord) => unknown;
|
|
679
797
|
};
|
|
680
798
|
onlineKeys?: boolean;
|
|
@@ -689,7 +807,9 @@ export interface McpPreflightState {
|
|
|
689
807
|
approved: Set<string> | null;
|
|
690
808
|
continuity: ContinuityRecord | null;
|
|
691
809
|
records: ContinuityRecord[];
|
|
692
|
-
pausedTools: Set<string> | null;
|
|
810
|
+
pausedTools: Set<string> | null; /** 1.13.0 */
|
|
811
|
+
baseline: BaselineTrust | null;
|
|
812
|
+
ready: Promise<BaselineTrust | null>;
|
|
693
813
|
}
|
|
694
814
|
export interface McpPreflightHooks {
|
|
695
815
|
state: McpPreflightState;
|
package/dist/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import * as S from "./staple.js";
|
|
|
4
4
|
import * as M from "./mcp.js";
|
|
5
5
|
import * as R from "./revocations.js";
|
|
6
6
|
import * as C from "./continuity.js";
|
|
7
|
+
import * as F from "./frameworks.js";
|
|
7
8
|
export const CONTINUITY_DEFAULT_POLICY = C.CONTINUITY_DEFAULT_POLICY;
|
|
8
9
|
export const CONTINUITY_CHANGE_CLASSES = C.CONTINUITY_CHANGE_CLASSES;
|
|
9
10
|
export const CONTINUITY_FAILURE_CLASSES = C.CONTINUITY_FAILURE_CLASSES;
|
|
@@ -20,6 +21,23 @@ export const lockParts = C.lockParts;
|
|
|
20
21
|
/** Which continuity failure class a refused guard() result is. */
|
|
21
22
|
export const continuityClassOf = C.continuityClassOf;
|
|
22
23
|
export const acceptanceOf = C.acceptanceOf;
|
|
24
|
+
export const CRAWLCHECK_LOCK_KIDS = C.CRAWLCHECK_LOCK_KIDS;
|
|
25
|
+
/** Is this the lockfile CrawlCheck or the operator signed? Offline. lock is the baseline to compare against (unsigned parts removed). */
|
|
26
|
+
export const verifyLockBaseline = C.verifyLockBaseline;
|
|
27
|
+
/** Sign a lockfile you approved (or edited) with your own Ed25519 key, over its canonical JSON. Returns a copy with operator_signature. */
|
|
28
|
+
export const signLock = C.signLock;
|
|
29
|
+
/** A new operator key: keep privateKey away from the agent; pass publicKey in operatorKeys. */
|
|
30
|
+
export const newOperatorKey = C.newOperatorKey;
|
|
31
|
+
/** Thrown in review/enforce when a record's outcome is pause, refresh or deny and nobody re-approved (or a paused tool is called). */
|
|
32
|
+
export const ContinuityRefused = F.ContinuityRefused;
|
|
33
|
+
/** The shared gate behind the framework adapters. Default mode observe. */
|
|
34
|
+
export const continuityGate = F.continuityGate;
|
|
35
|
+
/** 1.13.0: continuityGate that fails at startup: throws ContinuityRefused when the baseline is untrusted (outside observe). */
|
|
36
|
+
export const openContinuityGate = F.openContinuityGate;
|
|
37
|
+
/** @openai/agents: wrap an MCP server so every listTools() is checked against lock. Records at server.continuity. */
|
|
38
|
+
export const withContinuity = F.withContinuity;
|
|
39
|
+
/** @langchain/mcp-adapters: loadMcpTools with the check. client: a MultiServerMCPClient or a connected MCP SDK Client. */
|
|
40
|
+
export const loadMcpToolsWithContinuity = F.loadMcpToolsWithContinuity;
|
|
23
41
|
/** The signing policy as data (https://crawlcheck.io/keys#policy): object purposes, purpose chains, start times. */
|
|
24
42
|
export const KEY_POLICY = V.KEY_POLICY;
|
|
25
43
|
/** Build the policy input from a key directory you hold and, optionally, the rotation record saved with it. */
|
package/dist/mcp.js
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
// request is never sent.
|
|
18
18
|
import { normalizeDomain } from "./index.js";
|
|
19
19
|
import { checkLock, toolOperation } from "./index.js";
|
|
20
|
-
import { continuityCheck, continuityFailure, continuityClassOf } from "./continuity.js";
|
|
20
|
+
import { continuityCheck, continuityFailure, continuityClassOf, verifyLockBaseline, baselineRefusal } from "./continuity.js";
|
|
21
21
|
export class McpPreflightRefused extends Error {
|
|
22
22
|
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; }
|
|
23
23
|
}
|
|
@@ -62,6 +62,36 @@ export function mcpPreflight(cc, opts = {}) {
|
|
|
62
62
|
catch (e) { }
|
|
63
63
|
} return rec; };
|
|
64
64
|
const coOpts = function (extra) { return Object.assign({ mode: CO.mode, policy: CO.policy, exceptions: CO.exceptions, server: state.url }, extra || {}); };
|
|
65
|
+
// 1.13.0 signed baseline: the lockfile's signature is checked once, now, before anything is compared against it. Options
|
|
66
|
+
// (in opts.continuity, or top level without continuity): operatorKeys, keyPolicy, publishedKids, trustedKids,
|
|
67
|
+
// requireSignedBaseline. A bad or forged signature (or an unsigned lockfile when one is required) refuses the connect
|
|
68
|
+
// outside observe; observe records baseline_untrusted and goes on.
|
|
69
|
+
let baseLock = opts.lock || null, baseRefused = null;
|
|
70
|
+
state.baseline = null;
|
|
71
|
+
state.ready = !opts.lock ? Promise.resolve(null) : (async () => {
|
|
72
|
+
const bo = Object.assign({}, opts, CO || {}), req = bo.requireSignedBaseline ?? !!(bo.operatorKeys && bo.operatorKeys.length);
|
|
73
|
+
let b;
|
|
74
|
+
try {
|
|
75
|
+
b = await verifyLockBaseline(opts.lock, bo);
|
|
76
|
+
}
|
|
77
|
+
catch (e) {
|
|
78
|
+
b = { trust: "invalid", signer: null, kid: null, why: "the baseline could not be checked: " + (e && e.message || e), parts_used: false, lock: opts.lock };
|
|
79
|
+
}
|
|
80
|
+
state.baseline = { trust: b.trust, signer: b.signer, kid: b.kid, why: b.why, parts_used: b.parts_used };
|
|
81
|
+
baseLock = b.lock || opts.lock;
|
|
82
|
+
const bad = baselineRefusal(b, req);
|
|
83
|
+
if (bad && CO) {
|
|
84
|
+
const rec = await keep(await continuityFailure("baseline_untrusted", coOpts({ detail: String(bad).slice(0, 300) })));
|
|
85
|
+
if (rec.outcome !== "proceed")
|
|
86
|
+
baseRefused = new McpPreflightRefused("connect", rec.why.join("; ") + ": " + bad, { lock: rec });
|
|
87
|
+
}
|
|
88
|
+
else if (bad)
|
|
89
|
+
baseRefused = new McpPreflightRefused("connect", "the approved lockfile is not trusted: " + bad);
|
|
90
|
+
return state.baseline;
|
|
91
|
+
})();
|
|
92
|
+
state.ready.catch(function () { });
|
|
93
|
+
const baseOk = async function () { await state.ready; if (baseRefused)
|
|
94
|
+
throw baseRefused; };
|
|
65
95
|
const fresh = function () {
|
|
66
96
|
if (!state.receipt)
|
|
67
97
|
return false;
|
|
@@ -110,6 +140,7 @@ export function mcpPreflight(cc, opts = {}) {
|
|
|
110
140
|
state,
|
|
111
141
|
/** Before initialize. url: the server URL (a stdio/local server has none and is not decided; pass opts.local = "allow" | "refuse", default allow). */
|
|
112
142
|
async beforeConnect(url) {
|
|
143
|
+
await baseOk();
|
|
113
144
|
if (!url) {
|
|
114
145
|
if (opts.local === "refuse")
|
|
115
146
|
throw new McpPreflightRefused("connect", "a local (stdio) server cannot be resolved and opts.local is refuse");
|
|
@@ -127,8 +158,9 @@ export function mcpPreflight(cc, opts = {}) {
|
|
|
127
158
|
},
|
|
128
159
|
/** After initialize, with the tools the server lists. Refuses (and the wrapper closes the client) when they differ from the approved lockfile. */
|
|
129
160
|
async afterConnect(tools, meta) {
|
|
161
|
+
await baseOk();
|
|
130
162
|
if (CO && opts.lock) { // 1.11.0: classify, apply the operator's policy, keep the record
|
|
131
|
-
const rec = await keep(await continuityCheck(
|
|
163
|
+
const rec = await keep(await continuityCheck(baseLock, tools, coOpts({ complete: !(meta && meta.nextCursor), baselineTrust: state.baseline && state.baseline.trust })));
|
|
132
164
|
state.lockCheck = rec;
|
|
133
165
|
state.approved = null;
|
|
134
166
|
state.pausedTools = new Set(rec.paused_tools);
|
|
@@ -154,7 +186,7 @@ export function mcpPreflight(cc, opts = {}) {
|
|
|
154
186
|
state.approved = null;
|
|
155
187
|
return null;
|
|
156
188
|
}
|
|
157
|
-
const lc = await checkLock(
|
|
189
|
+
const lc = await checkLock(baseLock, tools || []);
|
|
158
190
|
state.lockCheck = lc;
|
|
159
191
|
state.approved = new Set((opts.lock.tools || []).map(function (t) { return t.name; }));
|
|
160
192
|
if (lc.decision !== "connect")
|
|
@@ -163,6 +195,7 @@ export function mcpPreflight(cc, opts = {}) {
|
|
|
163
195
|
},
|
|
164
196
|
/** Before tools/call. */
|
|
165
197
|
async beforeToolCall(name, annotations, tool) {
|
|
198
|
+
await baseOk();
|
|
166
199
|
if (state.host && !fresh())
|
|
167
200
|
await decide(state.host);
|
|
168
201
|
if (state.pausedTools && state.pausedTools.has(name)) { // 1.11.0: a changed or new tool waits for a person; the others stay usable
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crawlcheck/sdk",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.13.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",
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Signed baseline (1.13.0): the lockfile's signature is checked once, when a gate is created, before anything is compared
|
|
2
|
+
// against it. The cases are in the shared golden file (baseline_cases); the Python SDK must reproduce each one exactly.
|
|
3
|
+
import test from "node:test"; import assert from "node:assert/strict";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import { verifyLockBaseline, signLock, newOperatorKey, keyPolicyFrom, continuityGate, openContinuityGate, ContinuityRefused, withPreflight, McpPreflightRefused, CONTINUITY_FAILURE_CLASSES, CONTINUITY_DEFAULT_POLICY, canon } from "../dist/index.js";
|
|
6
|
+
const G = JSON.parse(fs.readFileSync(new URL("./fixtures/continuity.json", import.meta.url), "utf8"));
|
|
7
|
+
const keyDir = G.key_directory;
|
|
8
|
+
const sha = async (s) => Buffer.from(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(s))).toString("hex");
|
|
9
|
+
const B = (n) => G.baseline_cases.find((c) => c.name === n);
|
|
10
|
+
const quiet = { log: false };
|
|
11
|
+
const opts = async (o) => { const x = Object.assign({}, o); if (x.keyPolicyDirectory) { delete x.keyPolicyDirectory; x.keyPolicy = await keyPolicyFrom(keyDir); } return x; };
|
|
12
|
+
|
|
13
|
+
for (const c of G.baseline_cases) test("golden baseline: " + c.name, async () => {
|
|
14
|
+
const r = await verifyLockBaseline(c.lock, await opts(c.opts)); const { lock, ...rest } = r;
|
|
15
|
+
assert.deepEqual(rest, c.expect); assert.equal(await sha(canon(lock)), c.used_sha256);
|
|
16
|
+
});
|
|
17
|
+
test("golden: signLock is deterministic and matches the fixture", async () => {
|
|
18
|
+
assert.deepEqual(await signLock(G.lock, G.operator.privateKey), G.operator.signed_lock);
|
|
19
|
+
});
|
|
20
|
+
test("baseline_untrusted is the 14th failure class, deny by default, and an integrity class", async () => {
|
|
21
|
+
assert.equal(CONTINUITY_FAILURE_CLASSES.length, 14); assert.equal(CONTINUITY_FAILURE_CLASSES[13], "baseline_untrusted");
|
|
22
|
+
assert.equal(CONTINUITY_DEFAULT_POLICY.effects.baseline_untrusted, "deny");
|
|
23
|
+
for (const m of ["review", "enforce"]) assert.equal(G.failures.find((f) => f.cls === "baseline_untrusted" && f.opts.mode === m).expect.outcome, "deny");
|
|
24
|
+
assert.equal(G.failures.find((f) => f.cls === "baseline_untrusted" && f.opts.mode === "observe").expect.outcome, "proceed");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("gate, enforce: a lockfile edited after CrawlCheck signed it fails at startup", async () => {
|
|
28
|
+
const L = B("edited_after_signing").lock;
|
|
29
|
+
await assert.rejects(() => openContinuityGate(L, Object.assign({ mode: "enforce" }, quiet)), (e) => e instanceof ContinuityRefused && /baseline_untrusted|not trusted/.test(e.message) && e.record.events[0].class === "baseline_untrusted");
|
|
30
|
+
const g = continuityGate(L, Object.assign({ mode: "enforce" }, quiet)); await g.ready;
|
|
31
|
+
assert.equal(g.baseline.trust, "invalid"); assert.equal(g.records.length, 1);
|
|
32
|
+
await assert.rejects(() => g.check(G.tools_v1), ContinuityRefused);
|
|
33
|
+
});
|
|
34
|
+
test("gate, enforce: a lockfile re-signed with a foreign key fails at startup", async () => {
|
|
35
|
+
await assert.rejects(() => openContinuityGate(B("forged_foreign_key").lock, Object.assign({ mode: "enforce" }, quiet)), /not a CrawlCheck key/);
|
|
36
|
+
});
|
|
37
|
+
test("gate, observe: an untrusted baseline is recorded and passes", async () => {
|
|
38
|
+
const g = await openContinuityGate(B("edited_after_signing").lock, Object.assign({ mode: "observe" }, quiet));
|
|
39
|
+
assert.equal(g.records[0].events[0].class, "baseline_untrusted"); assert.equal(g.records[0].outcome, "proceed");
|
|
40
|
+
const r = await g.check([]); assert.equal(r.outcome, "proceed"); assert.equal(r.baseline.trust, "invalid");
|
|
41
|
+
});
|
|
42
|
+
test("gate: a CrawlCheck-signed lockfile is verified and named in every record", async () => {
|
|
43
|
+
const L = B("crawlcheck_signed").lock;
|
|
44
|
+
const g = await openContinuityGate(L, Object.assign({ mode: "enforce" }, quiet));
|
|
45
|
+
assert.equal(g.baseline.trust, "verified"); assert.equal(g.records.length, 0);
|
|
46
|
+
const r = await g.check([]); assert.equal(r.baseline.trust, "verified"); assert.equal(r.baseline.per_part, true);
|
|
47
|
+
});
|
|
48
|
+
test("gate: parts added after signing are not used for the comparison", async () => {
|
|
49
|
+
const g = await openContinuityGate(B("parts_added_after_signing").lock, Object.assign({ mode: "enforce" }, quiet));
|
|
50
|
+
assert.equal(g.baseline.trust, "verified"); assert.equal(g.baseline.parts_used, false);
|
|
51
|
+
const r = await g.check([]); assert.equal(r.baseline.per_part, false);
|
|
52
|
+
});
|
|
53
|
+
test("gate: unsigned passes unless a signed baseline is required; operator keys require one by default", async () => {
|
|
54
|
+
const g = await openContinuityGate(G.lock, Object.assign({ mode: "enforce" }, quiet)); assert.equal(g.baseline.trust, "unsigned");
|
|
55
|
+
await assert.rejects(() => openContinuityGate(G.lock, Object.assign({ mode: "enforce", requireSignedBaseline: true }, quiet)), /unsigned and a signed one is required/);
|
|
56
|
+
await assert.rejects(() => openContinuityGate(G.lock, Object.assign({ mode: "enforce", operatorKeys: [G.operator.publicKey] }, quiet)), /unsigned/);
|
|
57
|
+
await openContinuityGate(G.lock, Object.assign({ mode: "enforce", operatorKeys: [G.operator.publicKey], requireSignedBaseline: false }, quiet));
|
|
58
|
+
});
|
|
59
|
+
test("gate: an operator-signed lockfile with the operator key is operator_signed; with another key it is refused", async () => {
|
|
60
|
+
const g = await openContinuityGate(G.operator.signed_lock, Object.assign({ mode: "enforce", operatorKeys: [G.operator.publicKey] }, quiet));
|
|
61
|
+
assert.equal(g.baseline.trust, "operator_signed"); const r = await g.check(G.tools_v1); assert.equal(r.outcome, "proceed"); assert.equal(r.baseline.trust, "operator_signed");
|
|
62
|
+
await assert.rejects(() => openContinuityGate(G.operator.signed_lock, Object.assign({ mode: "enforce", operatorKeys: [G.operator.other_publicKey] }, quiet)), /not one of the operator keys/);
|
|
63
|
+
});
|
|
64
|
+
test("gate: an attacker who edits the operator-signed baseline to approve a new tool is refused at startup", async () => {
|
|
65
|
+
const L = structuredClone(G.operator.signed_lock); L.tools.push({ name: "export", sha256: "e".repeat(64) });
|
|
66
|
+
await assert.rejects(() => openContinuityGate(L, Object.assign({ mode: "enforce", operatorKeys: [G.operator.publicKey] }, quiet)), /changed after the operator signed it/);
|
|
67
|
+
const R = structuredClone(L); delete R.operator_signature; // stripping the signature instead: unsigned, refused because a key is configured
|
|
68
|
+
await assert.rejects(() => openContinuityGate(R, Object.assign({ mode: "enforce", operatorKeys: [G.operator.publicKey] }, quiet)), /unsigned/);
|
|
69
|
+
});
|
|
70
|
+
test("verified at startup, not per call: check() does not verify again", async () => {
|
|
71
|
+
let calls = 0; const g = continuityGate(B("crawlcheck_signed").lock, Object.assign({ mode: "enforce" }, quiet)); await g.ready;
|
|
72
|
+
const orig = crypto.subtle.verify; crypto.subtle.verify = async (...a) => { calls++; return orig.apply(crypto.subtle, a); };
|
|
73
|
+
try { for (let i = 0; i < 3; i++) await g.check([]); } finally { crypto.subtle.verify = orig; }
|
|
74
|
+
assert.equal(calls, 0);
|
|
75
|
+
});
|
|
76
|
+
test("newOperatorKey + signLock round trip", async () => {
|
|
77
|
+
const k = await newOperatorKey(); const s = await signLock(G.lock, k.privateKey);
|
|
78
|
+
assert.equal((await verifyLockBaseline(s, { operatorKeys: [k.publicKey] })).trust, "operator_signed");
|
|
79
|
+
assert.equal(s.operator_signature.kid, k.kid);
|
|
80
|
+
});
|
|
81
|
+
function cc() { return { async guard(host, action) { return { proceed: true, receipt: { kind: "crawlcheck-decision", decision: "allow", expires_at: new Date(Date.now() + 3600e3).toISOString() }, verification: { verified: true }, why: [] }; } }; }
|
|
82
|
+
test("withPreflight: an untrusted baseline refuses the connect before a byte is sent", async () => {
|
|
83
|
+
const sent = []; const c = { async connect() { sent.push("initialize"); }, async listTools() { return { tools: G.tools_v1 }; }, async callTool() { sent.push("call"); } };
|
|
84
|
+
withPreflight(c, cc(), { lock: B("edited_after_signing").lock, continuity: { mode: "enforce" } });
|
|
85
|
+
await assert.rejects(() => c.connect({ url: "https://mcp.example.com/mcp" }), (e) => e instanceof McpPreflightRefused && /baseline/.test(e.message));
|
|
86
|
+
assert.deepEqual(sent, []);
|
|
87
|
+
const c2 = { async connect() { sent.push("initialize"); }, async listTools() { return { tools: G.tools_v1 }; }, async callTool() { } };
|
|
88
|
+
withPreflight(c2, cc(), { lock: B("forged_foreign_key").lock }); // without continuity too
|
|
89
|
+
await assert.rejects(() => c2.connect({ url: "https://mcp.example.com/mcp" }), /lockfile is not trusted/);
|
|
90
|
+
const c3 = { async connect() { sent.push("initialize"); }, async listTools() { return { tools: G.tools_v1 }; }, async callTool() { } };
|
|
91
|
+
withPreflight(c3, cc(), { lock: G.operator.signed_lock, continuity: { mode: "enforce", operatorKeys: [G.operator.publicKey] }, toolReceipts: "none" });
|
|
92
|
+
await c3.connect({ url: "https://mcp.example.com/mcp" }); assert.equal(c3.preflight.state.baseline.trust, "operator_signed");
|
|
93
|
+
});
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
// Both SDKs must reproduce every expected field, including record_sha256, byte for byte.
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import * as C from "../../dist/continuity.js";
|
|
5
|
-
import {
|
|
5
|
+
import { canon } from "../../dist/verify.js";
|
|
6
|
+
import { lockFromTools, keyPolicyFrom } from "../../dist/index.js";
|
|
7
|
+
const rd = (f) => JSON.parse(fs.readFileSync(new URL("./" + f, import.meta.url), "utf8"));
|
|
6
8
|
const NOW = Date.parse("2026-11-07T16:00:00Z");
|
|
7
9
|
const v1 = [
|
|
8
10
|
{ name: "search", description: "Search the catalogue.", inputSchema: { type: "object", properties: { q: { type: "string" } }, required: ["q"] }, annotations: { readOnlyHint: true } },
|
|
@@ -36,11 +38,58 @@ const cases = [
|
|
|
36
38
|
["review_mode_change", lock, schemaChanged, { mode: "review" }],
|
|
37
39
|
["approved_exception", lock, descChanged, { exceptions: [{ tool: "search", class: "description_changed", by: "ops@example.com", reason: "vendor release 2.4 reviewed", until: "2026-12-01T00:00:00Z" }] }],
|
|
38
40
|
["expired_exception", lock, descChanged, { exceptions: [{ tool: "search", class: "description_changed", by: "ops@example.com", reason: "old", until: "2026-11-01T00:00:00Z" }] }],
|
|
39
|
-
["operator_policy_blocks_additions", lock, added, { policy: { id: "acme-strict", effects: { tool_added: "deny" } } }]
|
|
41
|
+
["operator_policy_blocks_additions", lock, added, { policy: { id: "acme-strict", effects: { tool_added: "deny" } } }],
|
|
42
|
+
["baseline_trust_named", lock, v1, { baselineTrust: "operator_signed" }]
|
|
40
43
|
].map(([name, L, tools, o]) => ({ name, lock: L, tools, opts: Object.assign({ now: NOW, server: S }, o) }));
|
|
41
44
|
for (const c of cases) c.expect = await C.continuityCheck(c.lock, c.tools, c.opts);
|
|
42
45
|
const failures = [];
|
|
43
46
|
for (const cls of C.CONTINUITY_FAILURE_CLASSES) for (const mode of ["observe", "review", "enforce"]) { const o = { now: NOW, server: S, mode }; failures.push({ cls, opts: o, expect: await C.continuityFailure(cls, o) }); }
|
|
47
|
+
// 1.13.0 signed baseline. Real CrawlCheck-signed lockfiles (GET /api/v1/mcp/lock?url=https://crawlcheck.io/mcp on 2026-11-07,
|
|
48
|
+
// one before and one after the signed lockfile gained parts), the key directory read the same day, and a TEST operator key
|
|
49
|
+
// derived from a public seed (never use it for anything real).
|
|
50
|
+
const signed = rd("lock-signed.json"), signedNoParts = rd("lock-signed-noparts.json"), keyDir = rd("key-directory-20261107.json");
|
|
51
|
+
const b64e = (u8) => Buffer.from(u8).toString("base64url");
|
|
52
|
+
const seedKey = async (label) => {
|
|
53
|
+
const seed = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(label)));
|
|
54
|
+
const pk = await crypto.subtle.importKey("pkcs8", Buffer.concat([Buffer.from("302e020100300506032b657004220420", "hex"), Buffer.from(seed)]), { name: "Ed25519" }, true, ["sign"]);
|
|
55
|
+
const j = await crypto.subtle.exportKey("jwk", pk); return { seed_hex: Buffer.from(seed).toString("hex"), privateKey: { kty: "OKP", crv: "Ed25519", x: j.x, d: j.d }, publicKey: { kty: "OKP", crv: "Ed25519", x: j.x }, key: pk };
|
|
56
|
+
};
|
|
57
|
+
const opKey = await seedKey("crawlcheck continuity fixture: TEST operator key, public, never use"), otherKey = await seedKey("crawlcheck continuity fixture: TEST other operator key"), forger = await seedKey("crawlcheck continuity fixture: TEST forger key");
|
|
58
|
+
const sha = async (s) => Buffer.from(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(s))).toString("hex");
|
|
59
|
+
const forged = clone(signed); forged.tools[0].sha256 = "f".repeat(64);
|
|
60
|
+
{ const body = {}; for (const k of Object.keys(forged)) if (k !== "sha256" && k !== "signature") body[k] = forged[k];
|
|
61
|
+
const d = await sha(canon(body)), msg = "crawlcheck-data-v1\n" + d, kid = b64e(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify({ crv: "Ed25519", kty: "OKP", x: forger.publicKey.x })))));
|
|
62
|
+
forged.sha256 = d; forged.signature = Object.assign({}, forged.signature, { kid, message: msg, sig: b64e(new Uint8Array(await crypto.subtle.sign({ name: "Ed25519" }, forger.key, new TextEncoder().encode(msg)))), key: Object.assign({}, forged.signature.key, { kid, jwk: forger.publicKey }) }); }
|
|
63
|
+
const opSigned = await C.signLock(lock, opKey.privateKey), opOverCc = await C.signLock(signed, opKey.privateKey);
|
|
64
|
+
const edited = (L, f) => { const x = clone(L); f(x); return x; };
|
|
65
|
+
const OK = [opKey.publicKey];
|
|
66
|
+
const baselines = [
|
|
67
|
+
["crawlcheck_signed", signed, {}],
|
|
68
|
+
["crawlcheck_signed_key_policy", signed, { keyPolicyDirectory: true }],
|
|
69
|
+
["crawlcheck_signed_other_trusted_kids", signed, { trustedKids: ["not-a-crawlcheck-key"] }],
|
|
70
|
+
["edited_after_signing", edited(signed, (x) => { x.tools[0].sha256 = "0".repeat(64); }), {}],
|
|
71
|
+
["parts_edited_after_signing", edited(signed, (x) => { x.parts[Object.keys(x.parts)[0]].description_sha256 = "0".repeat(64); }), {}],
|
|
72
|
+
["parts_added_after_signing", Object.assign(clone(signedNoParts), { parts: { best_source: { description_sha256: "0".repeat(64), schema_sha256: "0".repeat(64), annotations_sha256: "0".repeat(64) } } }), {}],
|
|
73
|
+
["forged_foreign_key", forged, {}],
|
|
74
|
+
["foreign_scheme", Object.assign(clone(lock), { signature: { kid: "x", message: "something-else\n00", sig: "AA" } }), {}],
|
|
75
|
+
["operator_signed", opSigned, { operatorKeys: OK }],
|
|
76
|
+
["operator_signed_key_as_x", opSigned, { operatorKeys: [opKey.publicKey.x] }],
|
|
77
|
+
["operator_wrong_key", opSigned, { operatorKeys: [otherKey.publicKey] }],
|
|
78
|
+
["operator_edited_after_signing", edited(opSigned, (x) => { x.tools.pop(); }), { operatorKeys: OK }],
|
|
79
|
+
["operator_bad_signature", edited(opSigned, (x) => { x.operator_signature.sig = opOverCc.operator_signature.sig; }), { operatorKeys: OK }],
|
|
80
|
+
["operator_no_key_given", opSigned, {}],
|
|
81
|
+
["operator_over_crawlcheck_no_key_given", opOverCc, {}],
|
|
82
|
+
["operator_over_crawlcheck", opOverCc, { operatorKeys: OK }],
|
|
83
|
+
["unsigned", lock, {}],
|
|
84
|
+
["not_a_lockfile", { tools_sha256: "x" }, {}]
|
|
85
|
+
];
|
|
86
|
+
const baselineCases = [];
|
|
87
|
+
for (const [name, L, o] of baselines) {
|
|
88
|
+
const oo = Object.assign({}, o); if (oo.keyPolicyDirectory) { delete oo.keyPolicyDirectory; oo.keyPolicy = await keyPolicyFrom(keyDir); }
|
|
89
|
+
const r = await C.verifyLockBaseline(L, oo); const { lock: used, ...expect } = r;
|
|
90
|
+
baselineCases.push({ name, lock: L, opts: o, expect, used_sha256: await sha(canon(used)) });
|
|
91
|
+
}
|
|
92
|
+
const operator = { warning: "TEST KEY derived from a public seed. Never use it for anything real.", seed_hex: opKey.seed_hex, privateKey: opKey.privateKey, publicKey: opKey.publicKey, other_publicKey: otherKey.publicKey, signed_lock: opSigned };
|
|
44
93
|
// acceptance: the split for one receipt
|
|
45
94
|
const F = { kind_ok: true, domain_matches: true, action_matches: true, times_present: true, not_expired: true, not_future: true, lifetime_ok: true, decision_known: true, resolve_bound: true };
|
|
46
95
|
const V = { integrity_valid: true, issuer_trusted: true, key_policy_valid: true, unavailable: [] };
|
|
@@ -63,6 +112,7 @@ const acc = [
|
|
|
63
112
|
["baseline_changed_in_receipt", R("allow", { assessed: { expected_tools_sha256: "b".repeat(64), tools_sha256: "c".repeat(64), matches: false } }), V, F, {}],
|
|
64
113
|
["partial_assessment", R("allow", { resolve: { sha256: "a".repeat(64), status: "partial" } }), Object.assign({}, V, { unavailable: ["ots_attestations"] }), F, {}]
|
|
65
114
|
].map(([name, receipt, verification, fields, opts]) => ({ name, receipt, verification, scope: { fields }, opts, expect: C.acceptanceOf(receipt, verification, { fields }, opts) }));
|
|
66
|
-
const out = { v: 1, generated_by: "sdk/test/fixtures/continuity-gen.mjs", now: NOW, default_policy_sha256: (await C.continuityPolicy()).sha256, lock, tools_v1: v1, cases, failures, acceptance: acc };
|
|
115
|
+
const out = { v: 1, generated_by: "sdk/test/fixtures/continuity-gen.mjs", now: NOW, default_policy_sha256: (await C.continuityPolicy()).sha256, lock, tools_v1: v1, cases, failures, acceptance: acc, baseline_cases: baselineCases, operator, key_directory: keyDir };
|
|
67
116
|
fs.writeFileSync(new URL("./continuity.json", import.meta.url), JSON.stringify(out, null, 1) + "\n");
|
|
68
|
-
console.log("cases", cases.length, "failures", failures.length, "acceptance", acc.length);
|
|
117
|
+
console.log("cases", cases.length, "failures", failures.length, "acceptance", acc.length, "baseline", baselineCases.length);
|
|
118
|
+
for (const b of baselineCases) console.log(" ", b.name.padEnd(40), b.expect.trust.padEnd(16), b.expect.why.slice(0, 90));
|