@crawlcheck/sdk 1.12.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 +26 -0
- package/dist/continuity.d.ts +44 -0
- package/dist/continuity.js +136 -6
- package/dist/frameworks.d.ts +23 -0
- package/dist/frameworks.js +47 -4
- package/dist/index.d.ts +75 -7
- package/dist/index.js +9 -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/README.md
CHANGED
|
@@ -179,6 +179,32 @@ const tools = await loadMcpToolsWithContinuity("shop", client, lock, { mode: "ob
|
|
|
179
179
|
|
|
180
180
|
`observe` records every difference (`console.warn`, `onRecord`, `server.continuity.records` / `tools.continuity.records`) and passes the tools through. `review` pauses the whole server on any change until `confirm` says yes. `enforce` applies the policy: a changed or new tool is withheld from the agent, and a call to it is refused, until `confirm({ tool, continuity })` re-approves it; the rest stay usable. A refusal throws `ContinuityRefused` (with `.record`). A yes is remembered for that exact definition, so a framework that lists tools every turn does not ask twice. For CrewAI, use the Python package (`crawlcheck.crewai.with_continuity`).
|
|
181
181
|
|
|
182
|
+
### Check the baseline before trusting it (1.13.0)
|
|
183
|
+
|
|
184
|
+
Whoever can edit your approved lockfile can approve their own tools. So every gate (`withPreflight` with `lock`, `continuityGate`, `withContinuity`, `loadMcpToolsWithContinuity`) checks the lockfile's signature once, when it is created, before it compares anything against it:
|
|
185
|
+
|
|
186
|
+
| `baseline.trust` | meaning |
|
|
187
|
+
|---|---|
|
|
188
|
+
| `verified` | signed by CrawlCheck (`cc.mcpLock(url)` / `GET /api/v1/mcp/lock`) with a CrawlCheck key. The package ships the published key ids (`CRAWLCHECK_LOCK_KIDS`); pass `keyPolicy: await cc.publishedKeyPolicy()` to follow a key rotation. These lockfiles carry `parts` inside the signature; `parts` added afterwards are not used. |
|
|
189
|
+
| `operator_signed` | you signed it with `signLock` and passed your public key in `operatorKeys`. Use this when you edit a lockfile yourself, for example to drop a tool. |
|
|
190
|
+
| `unsigned` | no signature. Accepted unless `requireSignedBaseline` (on by default once you pass `operatorKeys`). |
|
|
191
|
+
| `invalid` | the signature does not verify, the file was changed after signing, or the key is neither CrawlCheck's nor one of yours. |
|
|
192
|
+
|
|
193
|
+
`invalid`, or `unsigned` when a signed one is required, is the failure class `baseline_untrusted`: `enforce` and `review` refuse (`ContinuityRefused` / `McpPreflightRefused`), `observe` records it and goes on. `openContinuityGate(lock, opts)` throws at startup; `continuityGate` exposes `gate.ready` and `gate.baseline`. Every record names the result in `baseline.trust`. Nothing is verified per call.
|
|
194
|
+
|
|
195
|
+
Sign your own lockfile, with no command line:
|
|
196
|
+
|
|
197
|
+
```js
|
|
198
|
+
import { newOperatorKey, signLock, openContinuityGate } from "@crawlcheck/sdk";
|
|
199
|
+
|
|
200
|
+
const key = await newOperatorKey(); // once; store key.privateKey where the agent cannot read it
|
|
201
|
+
const signed = await signLock(lock, key.privateKey); // after you review (or edit) the lockfile; save signed
|
|
202
|
+
// at startup, with only the public key:
|
|
203
|
+
const gate = await openContinuityGate(signed, { mode: "enforce", operatorKeys: [key.publicKey] });
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
`verifyLockBaseline(lock, opts)` runs the same check on its own. The signature is Ed25519 over `"crawlcheck-lock-operator-v1\n" + sha256(canonical JSON of the lockfile without operator_signature)`; the Python package signs and verifies byte for byte the same.
|
|
207
|
+
|
|
182
208
|
## Do not trust the API: check the evidence yourself
|
|
183
209
|
|
|
184
210
|
```js
|
package/dist/continuity.d.ts
CHANGED
|
@@ -27,6 +27,7 @@ export declare const CONTINUITY_DEFAULT_POLICY: Readonly<{
|
|
|
27
27
|
check_timeout: "pause";
|
|
28
28
|
service_unavailable: "pause";
|
|
29
29
|
decision_refused: "deny";
|
|
30
|
+
baseline_untrusted: "deny";
|
|
30
31
|
}>;
|
|
31
32
|
}>;
|
|
32
33
|
/** Per-part hashes of one tool, so a change can be named (description, schema, annotations). */
|
|
@@ -63,6 +64,7 @@ export declare function continuityPolicy(p: any): Promise<{
|
|
|
63
64
|
check_timeout: "pause";
|
|
64
65
|
service_unavailable: "pause";
|
|
65
66
|
decision_refused: "deny";
|
|
67
|
+
baseline_untrusted: "deny";
|
|
66
68
|
}>;
|
|
67
69
|
allow_integrity_override: boolean;
|
|
68
70
|
} & {
|
|
@@ -103,3 +105,45 @@ export declare function acceptanceOf(receipt: any, verification: any, scope: any
|
|
|
103
105
|
};
|
|
104
106
|
/** Which failure class a refused guard() result is (for continuityFailure). */
|
|
105
107
|
export declare function continuityClassOf(g: any): "check_timeout" | "decision_refused" | "evidence_expired" | "key_retired" | "key_unknown" | "response_malformed" | "server_identity_mismatch" | "service_unavailable" | "signature_invalid";
|
|
108
|
+
/** CrawlCheck's lockfile signing keys (RFC 7638 thumbprints), as published at /.well-known/http-message-signatures-directory on 2026-11-07. */
|
|
109
|
+
export declare const CRAWLCHECK_LOCK_KIDS: readonly string[];
|
|
110
|
+
/** The operator's signature over the canonical lockfile (every field but operator_signature). privateKey: an Ed25519 private JWK ({kty, crv, x, d}). */
|
|
111
|
+
export declare function signLock(lock: any, privateKey: any): Promise<{
|
|
112
|
+
operator_signature: {
|
|
113
|
+
v: number;
|
|
114
|
+
alg: string;
|
|
115
|
+
kid: string;
|
|
116
|
+
message: string;
|
|
117
|
+
sig: string;
|
|
118
|
+
};
|
|
119
|
+
}>;
|
|
120
|
+
/** A new operator signing key: { privateKey (JWK, keep it out of the agent's reach), publicKey ({kty, crv, x}: pass it as operatorKeys) }. */
|
|
121
|
+
export declare function newOperatorKey(): Promise<{
|
|
122
|
+
privateKey: {
|
|
123
|
+
kty: string;
|
|
124
|
+
crv: string;
|
|
125
|
+
x: string | undefined;
|
|
126
|
+
d: string | undefined;
|
|
127
|
+
};
|
|
128
|
+
publicKey: {
|
|
129
|
+
kty: string;
|
|
130
|
+
crv: string;
|
|
131
|
+
x: string | undefined;
|
|
132
|
+
};
|
|
133
|
+
kid: string;
|
|
134
|
+
}>;
|
|
135
|
+
/**
|
|
136
|
+
* Is this baseline the one CrawlCheck or the operator signed? opts: operatorKeys (public JWKs or their x), keyPolicy,
|
|
137
|
+
* publishedKids, trustedKids. Returns { trust: verified | operator_signed | unsigned | invalid, signer, kid, why, parts_used,
|
|
138
|
+
* lock } where lock is the baseline to compare against (without unsigned parts). Offline; no network.
|
|
139
|
+
*/
|
|
140
|
+
export declare function verifyLockBaseline(lock: any, opts?: {}): Promise<{
|
|
141
|
+
trust: any;
|
|
142
|
+
signer: any;
|
|
143
|
+
kid: any;
|
|
144
|
+
why: any;
|
|
145
|
+
parts_used: boolean;
|
|
146
|
+
lock: any;
|
|
147
|
+
}>;
|
|
148
|
+
/** Why a baseline is refused (null when it is accepted). */
|
|
149
|
+
export declare function baselineRefusal(b: any, requireSigned: any): any;
|
package/dist/continuity.js
CHANGED
|
@@ -15,15 +15,19 @@
|
|
|
15
15
|
// enforce the policy's effects apply as written
|
|
16
16
|
// Effects, weakest to strongest: proceed, proceed_recorded (an approved exception), refresh, pause_tool, pause, deny.
|
|
17
17
|
// A failed exact comparison is never overridden by an interpretive assessment: nothing here reads descriptions for intent.
|
|
18
|
-
import { canon } from "./verify.js";
|
|
18
|
+
import { canon, keyPolicyCheck } from "./verify.js";
|
|
19
19
|
const subtle = globalThis.crypto && globalThis.crypto.subtle;
|
|
20
20
|
const enc = new TextEncoder();
|
|
21
21
|
const hex = (u8) => Array.from(u8, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
22
22
|
async function sha256Hex(s) { return hex(new Uint8Array(await subtle.digest("SHA-256", enc.encode(s)))); }
|
|
23
|
+
const b64d = (s) => { const t = String(s || "").replace(/-/g, "+").replace(/_/g, "/"); const bin = atob(t.padEnd(Math.ceil(t.length / 4) * 4, "=")); const o = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++)
|
|
24
|
+
o[i] = bin.charCodeAt(i); return o; };
|
|
25
|
+
const b64e = (u8) => { let s = ""; for (const b of u8)
|
|
26
|
+
s += String.fromCharCode(b); return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); };
|
|
23
27
|
export const CONTINUITY_EFFECTS = ["proceed", "proceed_recorded", "refresh", "pause_tool", "pause", "deny"];
|
|
24
28
|
export const CONTINUITY_CHANGE_CLASSES = ["tool_added", "tool_removed", "description_changed", "schema_changed", "annotations_changed", "tool_changed"];
|
|
25
|
-
export const CONTINUITY_FAILURE_CLASSES = ["evidence_expired", "signature_invalid", "key_unknown", "key_retired", "server_identity_mismatch", "server_unavailable", "tool_list_truncated", "response_malformed", "baseline_stale", "policy_version_mismatch", "check_timeout", "service_unavailable", "decision_refused"];
|
|
26
|
-
const INTEGRITY = ["signature_invalid", "key_unknown", "key_retired", "server_identity_mismatch"];
|
|
29
|
+
export const CONTINUITY_FAILURE_CLASSES = ["evidence_expired", "signature_invalid", "key_unknown", "key_retired", "server_identity_mismatch", "server_unavailable", "tool_list_truncated", "response_malformed", "baseline_stale", "policy_version_mismatch", "check_timeout", "service_unavailable", "decision_refused", "baseline_untrusted"];
|
|
30
|
+
const INTEGRITY = ["signature_invalid", "key_unknown", "key_retired", "server_identity_mismatch", "baseline_untrusted"];
|
|
27
31
|
/** The default operator policy. Every value can be overridden; the record names the policy's sha256, so what decided is attributable. */
|
|
28
32
|
export const CONTINUITY_DEFAULT_POLICY = Object.freeze({
|
|
29
33
|
v: 1, id: "crawlcheck-continuity-default", max_baseline_age_days: 90,
|
|
@@ -31,7 +35,8 @@ export const CONTINUITY_DEFAULT_POLICY = Object.freeze({
|
|
|
31
35
|
unchanged: "proceed", tool_added: "pause_tool", tool_removed: "proceed", description_changed: "pause_tool", schema_changed: "pause_tool",
|
|
32
36
|
annotations_changed: "pause_tool", tool_changed: "pause_tool", evidence_expired: "refresh", signature_invalid: "deny", key_unknown: "deny",
|
|
33
37
|
key_retired: "deny", server_identity_mismatch: "deny", server_unavailable: "pause", tool_list_truncated: "pause", response_malformed: "pause",
|
|
34
|
-
baseline_stale: "pause", policy_version_mismatch: "pause", check_timeout: "pause", service_unavailable: "pause", decision_refused: "deny"
|
|
38
|
+
baseline_stale: "pause", policy_version_mismatch: "pause", check_timeout: "pause", service_unavailable: "pause", decision_refused: "deny",
|
|
39
|
+
baseline_untrusted: "deny"
|
|
35
40
|
})
|
|
36
41
|
});
|
|
37
42
|
const WHY = {
|
|
@@ -44,7 +49,8 @@ const WHY = {
|
|
|
44
49
|
tool_list_truncated: "the tool list is incomplete (a further page was not read)", response_malformed: "the tool list could not be parsed",
|
|
45
50
|
baseline_stale: "the approved baseline is older than the policy allows", policy_version_mismatch: "the policy in force is not the version the deployment expects",
|
|
46
51
|
check_timeout: "the check did not finish in time", service_unavailable: "the verification service could not be reached; the local fallback applies",
|
|
47
|
-
decision_refused: "CrawlCheck's signed decision for the server's host did not allow the connection"
|
|
52
|
+
decision_refused: "CrawlCheck's signed decision for the server's host did not allow the connection",
|
|
53
|
+
baseline_untrusted: "the approved baseline is not trusted: its signature does not verify, its key is neither CrawlCheck's nor a given operator key, or it is unsigned where a signed one is required"
|
|
48
54
|
};
|
|
49
55
|
/** Per-part hashes of one tool, so a change can be named (description, schema, annotations). */
|
|
50
56
|
export async function lockToolParts(t) {
|
|
@@ -138,7 +144,7 @@ export async function continuityCheck(lock, tools, opts = {}) {
|
|
|
138
144
|
const was = {};
|
|
139
145
|
(lock?.tools ?? []).forEach((r) => { was[r.name] = r.sha256; });
|
|
140
146
|
const approvedAt = Date.parse(lock?.approved_at ?? "");
|
|
141
|
-
rec.baseline = { tools_sha256: lock?.tools_sha256 ?? null, approved_at: lock?.approved_at ?? null, age_days: isFinite(approvedAt) ? Math.floor((now - approvedAt) / 864e5) : null, server: lock?.server?.url ?? null, per_part: !!(lock && lock.parts) };
|
|
147
|
+
rec.baseline = { tools_sha256: lock?.tools_sha256 ?? null, approved_at: lock?.approved_at ?? null, age_days: isFinite(approvedAt) ? Math.floor((now - approvedAt) / 864e5) : null, server: lock?.server?.url ?? null, per_part: !!(lock && lock.parts), trust: opts.baselineTrust ?? null };
|
|
142
148
|
rec.observed = { tools_sha256: listHash, count: rows.length, complete: opts.complete !== false };
|
|
143
149
|
if (opts.expectPolicySha256 && opts.expectPolicySha256 !== pol.sha256)
|
|
144
150
|
ev(rec, pol, mode, "policy_version_mismatch", null, X, now, "expected " + opts.expectPolicySha256 + ", in force " + pol.sha256);
|
|
@@ -282,3 +288,127 @@ export function continuityClassOf(g) {
|
|
|
282
288
|
return "response_malformed";
|
|
283
289
|
return "decision_refused";
|
|
284
290
|
}
|
|
291
|
+
// ── signed baseline (1.13.0) ───────────────────────────────────────────────────────────────────────────────────────
|
|
292
|
+
// Whoever can edit the approved lockfile can approve their own tools. So the baseline's signature is checked once, when a
|
|
293
|
+
// gate is created (startup), before anything is compared against it:
|
|
294
|
+
// verified signed by CrawlCheck (/api/v1/mcp/lock, kind crawlcheck-mcp-lock, scheme crawlcheck-data-v1) with a
|
|
295
|
+
// CrawlCheck key: the pinned ids below, opts.publishedKids, or opts.keyPolicy (keyPolicyFrom /
|
|
296
|
+
// publishedKeyPolicy, which follows rotation). lock.parts added after signing is not covered: dropped.
|
|
297
|
+
// operator_signed signed with signLock() by a key in opts.operatorKeys (the operator's Ed25519 public keys).
|
|
298
|
+
// unsigned no signature. Accepted unless requireSignedBaseline (default: on when operatorKeys are given).
|
|
299
|
+
// invalid a signature that does not verify, a changed lockfile, or a key that is neither: baseline_untrusted.
|
|
300
|
+
/** CrawlCheck's lockfile signing keys (RFC 7638 thumbprints), as published at /.well-known/http-message-signatures-directory on 2026-11-07. */
|
|
301
|
+
export const CRAWLCHECK_LOCK_KIDS = Object.freeze(["1hHmE3fo1rg5Ig70wuFtJ5wBc7G-sUTgUJkxyPWFRps", "hcIAczGf-8EFBnkunmZlvFWnD2nHHeHeC9cM4BTiBVo"]);
|
|
302
|
+
const DATA_PREFIX = "crawlcheck-data-v1\n", OP_PREFIX = "crawlcheck-lock-operator-v1\n";
|
|
303
|
+
async function thumbOf(x) { return b64e(new Uint8Array(await subtle.digest("SHA-256", enc.encode(JSON.stringify({ crv: "Ed25519", kty: "OKP", x: String(x) }))))); }
|
|
304
|
+
async function edVerify(x, msg, sig) {
|
|
305
|
+
try {
|
|
306
|
+
const k = await subtle.importKey("jwk", { kty: "OKP", crv: "Ed25519", x: String(x) }, { name: "Ed25519" }, false, ["verify"]);
|
|
307
|
+
return await subtle.verify({ name: "Ed25519" }, k, b64d(sig), enc.encode(msg));
|
|
308
|
+
}
|
|
309
|
+
catch (e) {
|
|
310
|
+
return /not supported|unrecognized|unknown algorithm|NotSupported/i.test(String(e && (e.name + " " + e.message))) ? null : false;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const without = (o, keys) => { const b = {}; for (const k of Object.keys(o))
|
|
314
|
+
if (keys.indexOf(k) < 0)
|
|
315
|
+
b[k] = o[k]; return b; };
|
|
316
|
+
const xOf = (k) => (typeof k === "string" ? k : k && typeof k.x === "string" ? k.x : null);
|
|
317
|
+
/** The operator's signature over the canonical lockfile (every field but operator_signature). privateKey: an Ed25519 private JWK ({kty, crv, x, d}). */
|
|
318
|
+
export async function signLock(lock, privateKey) {
|
|
319
|
+
if (!lock || typeof lock !== "object" || !Array.isArray(lock.tools))
|
|
320
|
+
throw new Error("lock must be an mcp-lockfile (an object with a tools array)");
|
|
321
|
+
const j = privateKey || {};
|
|
322
|
+
if (typeof j.d !== "string" || typeof j.x !== "string")
|
|
323
|
+
throw new Error("privateKey must be an Ed25519 private JWK with d and x (see newOperatorKey)");
|
|
324
|
+
const body = without(lock, ["operator_signature"]), message = OP_PREFIX + (await sha256Hex(canon(body)));
|
|
325
|
+
const k = await subtle.importKey("jwk", { kty: "OKP", crv: "Ed25519", x: j.x, d: j.d }, { name: "Ed25519" }, false, ["sign"]);
|
|
326
|
+
const sig = b64e(new Uint8Array(await subtle.sign({ name: "Ed25519" }, k, enc.encode(message))));
|
|
327
|
+
return Object.assign(body, { operator_signature: { v: 1, alg: "Ed25519", kid: await thumbOf(j.x), message, sig } });
|
|
328
|
+
}
|
|
329
|
+
/** A new operator signing key: { privateKey (JWK, keep it out of the agent's reach), publicKey ({kty, crv, x}: pass it as operatorKeys) }. */
|
|
330
|
+
export async function newOperatorKey() {
|
|
331
|
+
const kp = await subtle.generateKey({ name: "Ed25519" }, true, ["sign", "verify"]), j = await subtle.exportKey("jwk", kp.privateKey);
|
|
332
|
+
return { privateKey: { kty: "OKP", crv: "Ed25519", x: j.x, d: j.d }, publicKey: { kty: "OKP", crv: "Ed25519", x: j.x }, kid: await thumbOf(j.x) };
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Is this baseline the one CrawlCheck or the operator signed? opts: operatorKeys (public JWKs or their x), keyPolicy,
|
|
336
|
+
* publishedKids, trustedKids. Returns { trust: verified | operator_signed | unsigned | invalid, signer, kid, why, parts_used,
|
|
337
|
+
* lock } where lock is the baseline to compare against (without unsigned parts). Offline; no network.
|
|
338
|
+
*/
|
|
339
|
+
export async function verifyLockBaseline(lock, opts = {}) {
|
|
340
|
+
const R = (trust, signer, kid, why, L) => ({ trust, signer, kid, why, parts_used: !!(L && L.parts && typeof L.parts === "object" && !Array.isArray(L.parts) && Object.keys(L.parts).length > 0), lock: L || lock });
|
|
341
|
+
if (!lock || typeof lock !== "object" || !Array.isArray(lock.tools))
|
|
342
|
+
return R("invalid", null, null, "the baseline is not an mcp-lockfile (an object with a tools array)", null);
|
|
343
|
+
const op = lock.operator_signature, okeys = (opts.operatorKeys || []).map(xOf).filter(Boolean);
|
|
344
|
+
if (op !== undefined && op !== null && (okeys.length || !lock.signature)) {
|
|
345
|
+
if (!okeys.length)
|
|
346
|
+
return R("invalid", null, null, "the baseline carries an operator signature, but no operator key was given to check it", null);
|
|
347
|
+
if (!op || typeof op !== "object" || op.alg !== "Ed25519" || typeof op.kid !== "string" || typeof op.sig !== "string" || typeof op.message !== "string")
|
|
348
|
+
return R("invalid", null, null, "the operator signature is malformed", null);
|
|
349
|
+
let x = null;
|
|
350
|
+
for (const k of okeys)
|
|
351
|
+
if ((await thumbOf(k)) === op.kid)
|
|
352
|
+
x = k;
|
|
353
|
+
if (!x)
|
|
354
|
+
return R("invalid", null, op.kid, "the operator signature is by key " + op.kid + ", which is not one of the operator keys given", null);
|
|
355
|
+
const sha = await sha256Hex(canon(without(lock, ["operator_signature"])));
|
|
356
|
+
if (op.message !== OP_PREFIX + sha)
|
|
357
|
+
return R("invalid", "operator", op.kid, "the baseline was changed after the operator signed it (it hashes to " + sha + ")", null);
|
|
358
|
+
const ok = await edVerify(x, op.message, op.sig);
|
|
359
|
+
if (ok === null)
|
|
360
|
+
return R("invalid", "operator", op.kid, "this runtime cannot verify Ed25519", null);
|
|
361
|
+
if (!ok)
|
|
362
|
+
return R("invalid", "operator", op.kid, "the operator signature does not verify", null);
|
|
363
|
+
return R("operator_signed", "operator", op.kid, "signed by operator key " + op.kid, lock);
|
|
364
|
+
}
|
|
365
|
+
const s = lock.signature;
|
|
366
|
+
if (s === undefined || s === null)
|
|
367
|
+
return R("unsigned", null, null, "the baseline carries no signature", lock);
|
|
368
|
+
if (lock.kind !== "crawlcheck-mcp-lock" || typeof s !== "object" || String(s.message || "").indexOf(DATA_PREFIX) !== 0)
|
|
369
|
+
return R("invalid", null, null, "the signature is not a CrawlCheck lockfile signature (kind crawlcheck-mcp-lock, scheme crawlcheck-data-v1)", null);
|
|
370
|
+
const kid = typeof s.kid === "string" ? s.kid : null;
|
|
371
|
+
let L = lock, sha = await sha256Hex(canon(without(lock, ["sha256", "signature", "operator_signature"])));
|
|
372
|
+
if (sha !== lock.sha256 && lock.parts !== undefined) {
|
|
373
|
+
const sha2 = await sha256Hex(canon(without(lock, ["sha256", "signature", "operator_signature", "parts"])));
|
|
374
|
+
if (sha2 === lock.sha256) {
|
|
375
|
+
sha = sha2;
|
|
376
|
+
L = without(lock, ["parts"]);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
if (sha !== lock.sha256)
|
|
380
|
+
return R("invalid", "crawlcheck", kid, "the baseline was changed after it was signed (it hashes to " + sha + ", the signature covers " + (lock.sha256 == null ? "null" : String(lock.sha256)) + ")", null);
|
|
381
|
+
if (s.message !== DATA_PREFIX + lock.sha256)
|
|
382
|
+
return R("invalid", "crawlcheck", kid, "the signature does not cover this baseline's digest", null);
|
|
383
|
+
const jx = s.key && s.key.jwk && s.key.jwk.x;
|
|
384
|
+
if (typeof jx !== "string")
|
|
385
|
+
return R("invalid", "crawlcheck", kid, "the signature carries no key", null);
|
|
386
|
+
if ((await thumbOf(jx)) !== kid)
|
|
387
|
+
return R("invalid", "crawlcheck", kid, "the signing key's thumbprint is not its stated key id", null);
|
|
388
|
+
const ok = await edVerify(jx, s.message, s.sig);
|
|
389
|
+
if (ok === null)
|
|
390
|
+
return R("invalid", "crawlcheck", kid, "this runtime cannot verify Ed25519", null);
|
|
391
|
+
if (!ok)
|
|
392
|
+
return R("invalid", "crawlcheck", kid, "the signature does not verify", null);
|
|
393
|
+
if (opts.keyPolicy != null) {
|
|
394
|
+
const kp = keyPolicyCheck(lock, opts.keyPolicy);
|
|
395
|
+
if (kp.ok !== true)
|
|
396
|
+
return R("invalid", "crawlcheck", kid, "the key policy rejects the signing key: " + kp.why, null);
|
|
397
|
+
}
|
|
398
|
+
else {
|
|
399
|
+
const ids = opts.publishedKids ?? opts.trustedKids ?? CRAWLCHECK_LOCK_KIDS;
|
|
400
|
+
if (ids.indexOf(kid) < 0)
|
|
401
|
+
return R("invalid", "crawlcheck", kid, "the baseline is signed by key " + kid + ", which is not a CrawlCheck key", null);
|
|
402
|
+
}
|
|
403
|
+
return R("verified", "crawlcheck", kid, "signed by CrawlCheck key " + kid + (L !== lock ? "; lock.parts was added after signing and is not used" : ""), L);
|
|
404
|
+
}
|
|
405
|
+
/** Why a baseline is refused (null when it is accepted). */
|
|
406
|
+
export function baselineRefusal(b, requireSigned) {
|
|
407
|
+
if (!b)
|
|
408
|
+
return "the baseline was not checked";
|
|
409
|
+
if (b.trust === "invalid")
|
|
410
|
+
return b.why;
|
|
411
|
+
if (b.trust === "unsigned" && requireSigned)
|
|
412
|
+
return "the baseline is unsigned and a signed one is required";
|
|
413
|
+
return null;
|
|
414
|
+
}
|
package/dist/frameworks.d.ts
CHANGED
|
@@ -10,6 +10,9 @@ export declare function continuityGate(lock: any, opts?: {}): {
|
|
|
10
10
|
records: never[];
|
|
11
11
|
record: null;
|
|
12
12
|
paused: Set<any>;
|
|
13
|
+
baseline: null;
|
|
14
|
+
ready: null;
|
|
15
|
+
readonly refused: any;
|
|
13
16
|
keepRecord(rec: any): Promise<any>;
|
|
14
17
|
ask(req: any): Promise<boolean>;
|
|
15
18
|
/** Compare the offered tools with the lock and apply the mode. Returns the record; throws ContinuityRefused. */
|
|
@@ -21,6 +24,26 @@ export declare function continuityGate(lock: any, opts?: {}): {
|
|
|
21
24
|
/** Refuse a call to a paused tool (a framework that cached an earlier list could still try it). */
|
|
22
25
|
callOk(name: any): boolean;
|
|
23
26
|
};
|
|
27
|
+
/** continuityGate, checked at startup: resolves to the gate once the baseline verifies, throws ContinuityRefused when it does not (outside observe). */
|
|
28
|
+
export declare function openContinuityGate(lock: any, opts?: {}): Promise<{
|
|
29
|
+
mode: any;
|
|
30
|
+
records: never[];
|
|
31
|
+
record: null;
|
|
32
|
+
paused: Set<any>;
|
|
33
|
+
baseline: null;
|
|
34
|
+
ready: null;
|
|
35
|
+
readonly refused: any;
|
|
36
|
+
keepRecord(rec: any): Promise<any>;
|
|
37
|
+
ask(req: any): Promise<boolean>;
|
|
38
|
+
/** Compare the offered tools with the lock and apply the mode. Returns the record; throws ContinuityRefused. */
|
|
39
|
+
check(tools: any, meta?: {}): Promise<any>;
|
|
40
|
+
/** Record a check that could not compare anything. Throws ContinuityRefused outside observe. */
|
|
41
|
+
fail(cls: any, detail: any): Promise<any>;
|
|
42
|
+
/** The tools the agent may see: all of them in observe mode, else without the paused ones. */
|
|
43
|
+
allowed(tools: any, nameOf?: (t: any) => any): any;
|
|
44
|
+
/** Refuse a call to a paused tool (a framework that cached an earlier list could still try it). */
|
|
45
|
+
callOk(name: any): boolean;
|
|
46
|
+
}>;
|
|
24
47
|
/**
|
|
25
48
|
* OpenAI Agents SDK (@openai/agents): wrap an MCP server (MCPServerStdio, MCPServerStreamableHttp, MCPServerSSE or your
|
|
26
49
|
* own MCPServer) so every listTools() the SDK makes is checked against lock. Everything else is the server's own.
|
package/dist/frameworks.js
CHANGED
|
@@ -10,7 +10,11 @@
|
|
|
10
10
|
// confirm(request) may return a promise; request is { continuity: record } for the server or { tool, continuity } for one
|
|
11
11
|
// tool. A yes is remembered for that exact definition, so a framework that lists tools every turn does not ask twice.
|
|
12
12
|
// No framework is a dependency of this package: each adapter imports its framework only when called.
|
|
13
|
-
|
|
13
|
+
// 1.13.0 signed baseline: when the gate is created it checks the lockfile's signature (verifyLockBaseline) before trusting
|
|
14
|
+
// it. opts.operatorKeys, keyPolicy, publishedKids, trustedKids, requireSignedBaseline. A bad or forged signature, or an
|
|
15
|
+
// unsigned lockfile when requireSignedBaseline, is recorded as baseline_untrusted; outside observe every check then throws.
|
|
16
|
+
// await gate.ready (or use openContinuityGate) to fail at startup.
|
|
17
|
+
import { continuityCheck, continuityFailure, verifyLockBaseline, baselineRefusal } from "./continuity.js";
|
|
14
18
|
import { canon } from "./verify.js";
|
|
15
19
|
export class ContinuityRefused extends Error {
|
|
16
20
|
constructor(why, record = null, tool = null) {
|
|
@@ -30,10 +34,12 @@ export function continuityGate(lock, opts = {}) {
|
|
|
30
34
|
if (!lock || !Array.isArray(lock.tools))
|
|
31
35
|
throw new Error("lock must be an approved mcp-lockfile (an object with a tools array); see https://crawlcheck.io/spec/mcp-lockfile");
|
|
32
36
|
const keep = opts.keep ?? 50, okTools = new Set(), okLists = new Set();
|
|
33
|
-
let lastKey = null;
|
|
37
|
+
let lastKey = null, base = lock, refused = null;
|
|
34
38
|
const co = { mode, policy: opts.policy, exceptions: opts.exceptions, server: opts.server ?? undefined, serverIdentity: opts.serverIdentity, expectPolicySha256: opts.expectPolicySha256 };
|
|
39
|
+
const requireSigned = opts.requireSignedBaseline ?? !!(opts.operatorKeys && opts.operatorKeys.length);
|
|
35
40
|
const gate = {
|
|
36
|
-
mode, records: [], record: null, paused: new Set(),
|
|
41
|
+
mode, records: [], record: null, paused: new Set(), baseline: null, ready: null,
|
|
42
|
+
get refused() { return refused; },
|
|
37
43
|
async keepRecord(rec) {
|
|
38
44
|
gate.record = rec;
|
|
39
45
|
gate.records = gate.records.concat([rec]).slice(-keep);
|
|
@@ -60,9 +66,12 @@ export function continuityGate(lock, opts = {}) {
|
|
|
60
66
|
},
|
|
61
67
|
/** Compare the offered tools with the lock and apply the mode. Returns the record; throws ContinuityRefused. */
|
|
62
68
|
async check(tools, meta = {}) {
|
|
69
|
+
await gate.ready;
|
|
70
|
+
if (refused)
|
|
71
|
+
throw refused;
|
|
63
72
|
const complete = meta.complete !== false;
|
|
64
73
|
const rows = Array.isArray(tools) ? tools.map(pick) : tools;
|
|
65
|
-
let rec = await continuityCheck(
|
|
74
|
+
let rec = await continuityCheck(base, rows, Object.assign({}, co, { complete, baselineTrust: gate.baseline && gate.baseline.trust }));
|
|
66
75
|
const key = (rec.observed?.tools_sha256 ?? "") + "|" + complete;
|
|
67
76
|
if (lastKey === key && gate.record)
|
|
68
77
|
rec = gate.record;
|
|
@@ -101,6 +110,9 @@ export function continuityGate(lock, opts = {}) {
|
|
|
101
110
|
},
|
|
102
111
|
/** Record a check that could not compare anything. Throws ContinuityRefused outside observe. */
|
|
103
112
|
async fail(cls, detail) {
|
|
113
|
+
await gate.ready;
|
|
114
|
+
if (refused)
|
|
115
|
+
throw refused;
|
|
104
116
|
const rec = await continuityFailure(cls, Object.assign({}, co, { detail: detail ? String(detail).slice(0, 300) : undefined }));
|
|
105
117
|
lastKey = null;
|
|
106
118
|
await gate.keepRecord(rec);
|
|
@@ -114,8 +126,36 @@ export function continuityGate(lock, opts = {}) {
|
|
|
114
126
|
callOk(name) { if (mode !== "observe" && gate.paused.has(name))
|
|
115
127
|
throw new ContinuityRefused("tool " + name + " changed since it was approved (or is new) and no person re-approved it", gate.record, name); return true; },
|
|
116
128
|
};
|
|
129
|
+
gate.ready = (async () => {
|
|
130
|
+
let b;
|
|
131
|
+
try {
|
|
132
|
+
b = await verifyLockBaseline(lock, opts);
|
|
133
|
+
}
|
|
134
|
+
catch (e) {
|
|
135
|
+
b = { trust: "invalid", signer: null, kid: null, why: "the baseline could not be checked: " + (e && e.message || e), parts_used: false, lock };
|
|
136
|
+
}
|
|
137
|
+
gate.baseline = { trust: b.trust, signer: b.signer, kid: b.kid, why: b.why, parts_used: b.parts_used };
|
|
138
|
+
base = b.lock || lock;
|
|
139
|
+
const bad = baselineRefusal(b, requireSigned);
|
|
140
|
+
if (bad) {
|
|
141
|
+
const rec = await continuityFailure("baseline_untrusted", Object.assign({}, co, { detail: String(bad).slice(0, 300) }));
|
|
142
|
+
await gate.keepRecord(rec);
|
|
143
|
+
if (mode !== "observe" && rec.outcome !== "proceed")
|
|
144
|
+
refused = new ContinuityRefused(rec.why.join("; ") + ": " + bad, rec);
|
|
145
|
+
}
|
|
146
|
+
return gate.baseline;
|
|
147
|
+
})();
|
|
148
|
+
gate.ready.catch(() => { }); // a bad policy is thrown by check(), which awaits ready
|
|
117
149
|
return gate;
|
|
118
150
|
}
|
|
151
|
+
/** continuityGate, checked at startup: resolves to the gate once the baseline verifies, throws ContinuityRefused when it does not (outside observe). */
|
|
152
|
+
export async function openContinuityGate(lock, opts = {}) {
|
|
153
|
+
const g = continuityGate(lock, opts);
|
|
154
|
+
await g.ready;
|
|
155
|
+
if (g.refused)
|
|
156
|
+
throw g.refused;
|
|
157
|
+
return g;
|
|
158
|
+
}
|
|
119
159
|
const urlOf = (s) => {
|
|
120
160
|
for (const p of [s && s.params, s && s.underlying && s.underlying.params, s && s.options])
|
|
121
161
|
if (p && typeof p.url === "string" && p.url)
|
|
@@ -198,6 +238,9 @@ export async function loadMcpToolsWithContinuity(serverName, client, lock, opts
|
|
|
198
238
|
conn = null;
|
|
199
239
|
}
|
|
200
240
|
const gate = opts.gate || continuityGate(lock, Object.assign({ server: opts.serverUrl ?? (conn && conn.url) ?? undefined }, opts));
|
|
241
|
+
await gate.ready;
|
|
242
|
+
if (gate.refused)
|
|
243
|
+
throw gate.refused; // 1.13.0: an untrusted baseline fails before the server is read
|
|
201
244
|
let listed;
|
|
202
245
|
try {
|
|
203
246
|
listed = await listAll(mcp);
|
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,7 +128,66 @@ export declare const acceptanceOf: (receipt: unknown, verification: unknown, sco
|
|
|
126
128
|
confirmed?: boolean | null;
|
|
127
129
|
lock?: unknown;
|
|
128
130
|
}) => Acceptance;
|
|
129
|
-
|
|
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 {
|
|
130
191
|
/** A person's re-approval: { continuity } for the server, { tool, continuity } for one tool. Remembered per definition. */
|
|
131
192
|
confirm?: (request: {
|
|
132
193
|
tool?: string;
|
|
@@ -143,7 +204,10 @@ export interface ContinuityGate {
|
|
|
143
204
|
mode: "observe" | "review" | "enforce";
|
|
144
205
|
records: ContinuityRecord[];
|
|
145
206
|
record: ContinuityRecord | null;
|
|
146
|
-
paused: Set<string>;
|
|
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;
|
|
147
211
|
check(tools: McpTool[], meta?: {
|
|
148
212
|
complete?: boolean;
|
|
149
213
|
}): Promise<ContinuityRecord>;
|
|
@@ -159,6 +223,8 @@ export declare const ContinuityRefused: new (why: string, record?: ContinuityRec
|
|
|
159
223
|
};
|
|
160
224
|
/** The shared gate behind the framework adapters. Default mode observe. */
|
|
161
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>;
|
|
162
228
|
/** @openai/agents: wrap an MCP server so every listTools() is checked against lock. Records at server.continuity. */
|
|
163
229
|
export declare const withContinuity: <S extends {
|
|
164
230
|
listTools: (...a: any[]) => Promise<any[]>;
|
|
@@ -707,7 +773,7 @@ export declare const applyRevocations: (store: RevocationStore, doc: unknown, no
|
|
|
707
773
|
export declare const revokedFor: (store: RevocationStore, domain: string, action?: string) => (RevokedEntry & {
|
|
708
774
|
domain: string;
|
|
709
775
|
}) | null;
|
|
710
|
-
export interface McpPreflightOptions extends PreflightOptions {
|
|
776
|
+
export interface McpPreflightOptions extends PreflightOptions, BaselineOptions {
|
|
711
777
|
/** Proceed on warn too (default false). */
|
|
712
778
|
allowWarn?: boolean;
|
|
713
779
|
/** Called on require_confirmation (and, with confirmDestructive, before a destructive tool call); the connect or call proceeds only if it resolves true. */
|
|
@@ -726,7 +792,7 @@ export interface McpPreflightOptions extends PreflightOptions {
|
|
|
726
792
|
/** The server URL when the transport does not expose it. */
|
|
727
793
|
serverUrl?: string;
|
|
728
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 */
|
|
729
|
-
continuity?: ContinuityOptions & {
|
|
795
|
+
continuity?: ContinuityOptions & BaselineOptions & {
|
|
730
796
|
onRecord?: (record: ContinuityRecord) => unknown;
|
|
731
797
|
};
|
|
732
798
|
onlineKeys?: boolean;
|
|
@@ -741,7 +807,9 @@ export interface McpPreflightState {
|
|
|
741
807
|
approved: Set<string> | null;
|
|
742
808
|
continuity: ContinuityRecord | null;
|
|
743
809
|
records: ContinuityRecord[];
|
|
744
|
-
pausedTools: Set<string> | null;
|
|
810
|
+
pausedTools: Set<string> | null; /** 1.13.0 */
|
|
811
|
+
baseline: BaselineTrust | null;
|
|
812
|
+
ready: Promise<BaselineTrust | null>;
|
|
745
813
|
}
|
|
746
814
|
export interface McpPreflightHooks {
|
|
747
815
|
state: McpPreflightState;
|
package/dist/index.js
CHANGED
|
@@ -21,10 +21,19 @@ export const lockParts = C.lockParts;
|
|
|
21
21
|
/** Which continuity failure class a refused guard() result is. */
|
|
22
22
|
export const continuityClassOf = C.continuityClassOf;
|
|
23
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;
|
|
24
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). */
|
|
25
32
|
export const ContinuityRefused = F.ContinuityRefused;
|
|
26
33
|
/** The shared gate behind the framework adapters. Default mode observe. */
|
|
27
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;
|
|
28
37
|
/** @openai/agents: wrap an MCP server so every listTools() is checked against lock. Records at server.continuity. */
|
|
29
38
|
export const withContinuity = F.withContinuity;
|
|
30
39
|
/** @langchain/mcp-adapters: loadMcpTools with the check. client: a MultiServerMCPClient or a connected MCP SDK Client. */
|