@crawlcheck/sdk 1.9.2 → 1.11.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/dist/index.d.ts CHANGED
@@ -1,6 +1,131 @@
1
1
  import type { paths, components } from "./gen/openapi.js";
2
2
  import * as S from "./staple.js";
3
3
  export type { paths, components };
4
+ export interface Acceptance {
5
+ integrity_valid: boolean;
6
+ issuer_trusted: boolean | null;
7
+ key_policy_valid: boolean | null;
8
+ freshness_valid: boolean;
9
+ subject_matches: boolean;
10
+ operation_matches: boolean | null;
11
+ replay_safe: boolean | null;
12
+ baseline_matches: boolean | null;
13
+ assessment_complete: boolean;
14
+ decision: string | null;
15
+ policy_source: string | null;
16
+ evidence_level: "A" | "B" | "C" | null;
17
+ policy_result: "proceed" | "pause" | "deny";
18
+ accepted: boolean;
19
+ why: string[];
20
+ }
21
+ export type ContinuityEffect = "proceed" | "proceed_recorded" | "refresh" | "pause_tool" | "pause" | "deny";
22
+ export interface ContinuityPolicy {
23
+ v?: 1;
24
+ id?: string;
25
+ max_baseline_age_days?: number;
26
+ effects?: Partial<Record<string, ContinuityEffect>>;
27
+ allow_integrity_override?: boolean;
28
+ }
29
+ export interface ContinuityException {
30
+ tool?: string;
31
+ class: string;
32
+ until?: string;
33
+ by?: string;
34
+ reason?: string;
35
+ }
36
+ export interface ContinuityEvent {
37
+ class: string;
38
+ tool: string | null;
39
+ why: string;
40
+ policy_effect: ContinuityEffect;
41
+ effect: ContinuityEffect;
42
+ exception?: {
43
+ by: string | null;
44
+ reason: string | null;
45
+ until: string | null;
46
+ };
47
+ detail?: string;
48
+ }
49
+ export interface ContinuityRecord {
50
+ kind: "crawlcheck-continuity-record";
51
+ v: 1;
52
+ checked_at: string;
53
+ mode: "observe" | "review" | "enforce";
54
+ server: string | null;
55
+ policy: {
56
+ id: string;
57
+ sha256: string;
58
+ integrity_override: boolean;
59
+ };
60
+ claims: string;
61
+ baseline: {
62
+ tools_sha256: string | null;
63
+ approved_at: string | null;
64
+ age_days: number | null;
65
+ server: string | null;
66
+ per_part: boolean;
67
+ } | null;
68
+ observed: {
69
+ tools_sha256: string;
70
+ count: number;
71
+ complete: boolean;
72
+ } | null;
73
+ events: ContinuityEvent[];
74
+ would: ContinuityEffect;
75
+ enforced: boolean;
76
+ outcome: "proceed" | "refresh" | "pause" | "deny";
77
+ paused_tools: string[];
78
+ why: string[];
79
+ record_sha256: string;
80
+ }
81
+ export interface ContinuityOptions {
82
+ mode?: "observe" | "review" | "enforce";
83
+ policy?: ContinuityPolicy;
84
+ exceptions?: ContinuityException[];
85
+ complete?: boolean;
86
+ server?: string;
87
+ serverIdentity?: string;
88
+ expectPolicySha256?: string;
89
+ now?: number;
90
+ detail?: string;
91
+ }
92
+ export declare const CONTINUITY_DEFAULT_POLICY: {
93
+ v: 1;
94
+ id: string;
95
+ max_baseline_age_days: number;
96
+ effects: Record<string, ContinuityEffect>;
97
+ };
98
+ export declare const CONTINUITY_CHANGE_CLASSES: string[];
99
+ export declare const CONTINUITY_FAILURE_CLASSES: string[];
100
+ /** Compare offered tools with the approved baseline and apply the operator's policy; returns the record to keep. */
101
+ export declare const continuityCheck: (lock: McpLock, tools: unknown, opts?: ContinuityOptions) => Promise<ContinuityRecord>;
102
+ /** The record for a check that could not compare (server, evidence or service failed). */
103
+ export declare const continuityFailure: (cls: string, opts?: ContinuityOptions) => Promise<ContinuityRecord>;
104
+ /** The policy in force (default + yours) and its sha256. */
105
+ export declare const continuityPolicy: (p?: ContinuityPolicy) => Promise<Required<ContinuityPolicy> & {
106
+ sha256: string;
107
+ }>;
108
+ /** Per-part hashes (description, schema, annotations) so a change can be named. Store lockParts(tools) as lock.parts. */
109
+ export declare const lockToolParts: (t: McpTool) => Promise<{
110
+ description_sha256: string;
111
+ schema_sha256: string;
112
+ annotations_sha256: string;
113
+ }>;
114
+ export declare const lockParts: (tools: McpTool[]) => Promise<Record<string, {
115
+ description_sha256: string;
116
+ schema_sha256: string;
117
+ annotations_sha256: string;
118
+ }>>;
119
+ /** The separate acceptance questions for one decision receipt. Act on policy_result, never on integrity_valid alone. */
120
+ /** Which continuity failure class a refused guard() result is. */
121
+ export declare const continuityClassOf: (g: GuardResult | null) => string;
122
+ export declare const acceptanceOf: (receipt: unknown, verification: unknown, scope: {
123
+ fields?: unknown;
124
+ } | null, opts?: {
125
+ allowWarn?: boolean;
126
+ confirmed?: boolean | null;
127
+ lock?: unknown;
128
+ }) => Acceptance;
4
129
  /** One verifier check. ok: true passed, false FAILED, null could not run (why says why) - never a pass. */
5
130
  export interface VerifyCheck {
6
131
  id: string;
@@ -180,7 +305,12 @@ export interface McpLock {
180
305
  tools: {
181
306
  name: string;
182
307
  sha256: string;
183
- }[];
308
+ }[]; /** 1.11.0: per-part hashes (lockParts) so continuityCheck can name what changed. */
309
+ parts?: Record<string, {
310
+ description_sha256: string;
311
+ schema_sha256: string;
312
+ annotations_sha256: string;
313
+ }>;
184
314
  descriptions?: Record<string, string>;
185
315
  server?: {
186
316
  url?: string | null;
@@ -303,16 +433,31 @@ export interface DecisionWant {
303
433
  * time window, per-action lifetime, known decision, resolve binding); the signature and issuer are checked by
304
434
  * verifyDocument. guard() runs both. https://crawlcheck.io/spec/guard
305
435
  */
436
+ export interface DecisionScopeFields {
437
+ kind_ok: boolean;
438
+ domain_matches: boolean;
439
+ action_matches: boolean;
440
+ operation_matches?: boolean;
441
+ nonce_matches?: boolean;
442
+ times_present: boolean;
443
+ not_expired: boolean;
444
+ not_future: boolean;
445
+ lifetime_ok: boolean;
446
+ decision_known: boolean;
447
+ resolve_bound: boolean;
448
+ }
306
449
  export declare function checkDecision(receipt: unknown, want: DecisionWant): {
307
450
  ok: boolean;
308
451
  why: string[];
452
+ fields: DecisionScopeFields;
309
453
  };
310
454
  export interface GuardResult {
311
455
  proceed: boolean;
312
456
  receipt: DecisionReceipt | null;
313
457
  verification: (DocumentVerification & Trust) | null;
314
458
  header: Record<string, string> | null;
315
- why: string[];
459
+ why: string[]; /** 1.11.0: the separate acceptance questions (integrity, issuer, key policy, freshness, subject, operation, replay, baseline, completeness) and policy_result. Null when no receipt came back. */
460
+ acceptance: Acceptance | null;
316
461
  split_view?: LogFork[]; /** Set when the domain was refused from the held revocation list, with no network call. */
317
462
  revoked?: RevokedEntry;
318
463
  }
@@ -528,6 +673,10 @@ export interface McpPreflightOptions extends PreflightOptions {
528
673
  local?: "allow" | "refuse";
529
674
  /** The server URL when the transport does not expose it. */
530
675
  serverUrl?: string;
676
+ /** 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 & {
678
+ onRecord?: (record: ContinuityRecord) => unknown;
679
+ };
531
680
  onlineKeys?: boolean;
532
681
  }
533
682
  export interface McpPreflightState {
@@ -536,8 +685,11 @@ export interface McpPreflightState {
536
685
  receipt: DecisionReceipt | null;
537
686
  verification: (DocumentVerification & Trust) | null;
538
687
  decidedAt: number;
539
- lockCheck: McpLockCheck | null;
688
+ lockCheck: McpLockCheck | ContinuityRecord | null;
540
689
  approved: Set<string> | null;
690
+ continuity: ContinuityRecord | null;
691
+ records: ContinuityRecord[];
692
+ pausedTools: Set<string> | null;
541
693
  }
542
694
  export interface McpPreflightHooks {
543
695
  state: McpPreflightState;
@@ -547,7 +699,9 @@ export interface McpPreflightHooks {
547
699
  receipt: DecisionReceipt | null;
548
700
  verification?: DocumentVerification & Trust;
549
701
  }>;
550
- afterConnect(tools: McpTool[]): Promise<McpLockCheck | null>;
702
+ afterConnect(tools: McpTool[] | null, meta?: {
703
+ nextCursor?: string | null;
704
+ } | null): Promise<McpLockCheck | ContinuityRecord | null>;
551
705
  beforeToolCall(name: string, annotations?: unknown): Promise<{
552
706
  receipt: DecisionReceipt | null;
553
707
  }>;
@@ -762,4 +916,23 @@ export declare function toolsFor(format: ToolFormat, tools: AgentTool[]): {
762
916
  tools: unknown;
763
917
  call: (name: string, args: unknown) => Promise<JsonObject>;
764
918
  };
919
+ /** Structural view of the Vercel AI SDK module ("ai") - passed in, so this package keeps no dependency. */
920
+ export interface VercelAiModule {
921
+ tool: (def: any) => any;
922
+ jsonSchema: (schema: any) => any;
923
+ }
924
+ /** Vercel AI SDK adapter (1.10.0): `import * as ai from "ai"; const tools = vercelTools(cc, ai);` then generateText({ tools, ... }).
925
+ * Same three tools as agentTools(); they fail closed. The schema is given as both inputSchema (AI SDK 5+) and parameters (AI SDK 4). */
926
+ export declare function vercelTools(cc: CrawlCheck, ai: VercelAiModule, opts?: AgentToolOptions): Record<string, unknown>;
927
+ /** Structural view of "@langchain/core/tools" - passed in, so this package keeps no dependency. */
928
+ export interface LangChainToolsModule {
929
+ tool: (fn: (input: any) => any, fields: {
930
+ name: string;
931
+ description: string;
932
+ schema: any;
933
+ }) => any;
934
+ }
935
+ /** LangChain.js adapter (1.10.0): `import * as lc from "@langchain/core/tools"; const tools = langchainTools(cc, lc);`
936
+ * Same three tools as agentTools(); each returns its result as a JSON string; they fail closed. */
937
+ export declare function langchainTools(cc: CrawlCheck, lc: LangChainToolsModule, opts?: AgentToolOptions): unknown[];
765
938
  export default CrawlCheck;
package/dist/index.js CHANGED
@@ -3,6 +3,23 @@ import * as L from "./log.js";
3
3
  import * as S from "./staple.js";
4
4
  import * as M from "./mcp.js";
5
5
  import * as R from "./revocations.js";
6
+ import * as C from "./continuity.js";
7
+ export const CONTINUITY_DEFAULT_POLICY = C.CONTINUITY_DEFAULT_POLICY;
8
+ export const CONTINUITY_CHANGE_CLASSES = C.CONTINUITY_CHANGE_CLASSES;
9
+ export const CONTINUITY_FAILURE_CLASSES = C.CONTINUITY_FAILURE_CLASSES;
10
+ /** Compare offered tools with the approved baseline and apply the operator's policy; returns the record to keep. */
11
+ export const continuityCheck = C.continuityCheck;
12
+ /** The record for a check that could not compare (server, evidence or service failed). */
13
+ export const continuityFailure = C.continuityFailure;
14
+ /** The policy in force (default + yours) and its sha256. */
15
+ export const continuityPolicy = C.continuityPolicy;
16
+ /** Per-part hashes (description, schema, annotations) so a change can be named. Store lockParts(tools) as lock.parts. */
17
+ export const lockToolParts = C.lockToolParts;
18
+ export const lockParts = C.lockParts;
19
+ /** The separate acceptance questions for one decision receipt. Act on policy_result, never on integrity_valid alone. */
20
+ /** Which continuity failure class a refused guard() result is. */
21
+ export const continuityClassOf = C.continuityClassOf;
22
+ export const acceptanceOf = C.acceptanceOf;
6
23
  /** The signing policy as data (https://crawlcheck.io/keys#policy): object purposes, purpose chains, start times. */
7
24
  export const KEY_POLICY = V.KEY_POLICY;
8
25
  /** Build the policy input from a key directory you hold and, optionally, the rotation record saved with it. */
@@ -84,46 +101,63 @@ export const ACTION_MAX_LIFETIME_S = { read: 86400, cite: 86400, connect: 3600,
84
101
  /** The most clock skew a receipt may claim (seconds). */
85
102
  export const MAX_CLOCK_SKEW_S = 300;
86
103
  const DECISIONS = ["allow", "warn", "require_confirmation", "block", "unsupported"];
87
- /**
88
- * Is this receipt about the operation you are about to perform? Checks the scope only (kind, domain, action, nonce,
89
- * time window, per-action lifetime, known decision, resolve binding); the signature and issuer are checked by
90
- * verifyDocument. guard() runs both. https://crawlcheck.io/spec/guard
91
- */
92
104
  export function checkDecision(receipt, want) {
93
105
  const why = [], r = receipt;
106
+ const f = { kind_ok: false, domain_matches: false, action_matches: false, times_present: false, not_expired: false, not_future: false, lifetime_ok: false, decision_known: false, resolve_bound: false };
107
+ if (want.operation)
108
+ f.operation_matches = false;
109
+ if (want.nonce)
110
+ f.nonce_matches = false;
94
111
  if (!r || typeof r !== "object" || Array.isArray(r))
95
- return { ok: false, why: ["no receipt: CrawlCheck sent " + (r === null || r === undefined ? "nothing" : typeof r)] };
96
- if (r.kind !== "crawlcheck-decision" || r.v !== 1)
112
+ return { ok: false, why: ["no receipt: CrawlCheck sent " + (r === null || r === undefined ? "nothing" : typeof r)], fields: f };
113
+ f.kind_ok = r.kind === "crawlcheck-decision" && r.v === 1;
114
+ if (!f.kind_ok)
97
115
  why.push("not a v1 decision receipt (kind " + r.kind + ", v " + r.v + ")");
98
116
  const wd = normalizeDomain(want.domain);
99
- if (!wd || normalizeDomain(r.domain) !== wd)
117
+ f.domain_matches = !!wd && normalizeDomain(r.domain) === wd;
118
+ if (!f.domain_matches)
100
119
  why.push("the receipt is for " + r.domain + ", not " + want.domain);
101
- if (String(r.action) !== String(want.action))
120
+ f.action_matches = String(r.action) === String(want.action);
121
+ if (!f.action_matches)
102
122
  why.push("the receipt is for action " + r.action + ", not " + want.action);
103
- if (want.operation && canon(r.operation ?? null) !== canon(want.operation))
104
- why.push(r.operation ? "the receipt is for another operation (" + canon(r.operation).slice(0, 120) + ")" : "the receipt is not scoped to an operation, and this request named one");
105
- if (want.nonce && r.nonce !== want.nonce)
106
- why.push(r.nonce ? "the receipt carries another request's nonce (replayed)" : "the receipt does not carry the nonce this request sent");
123
+ if (want.operation) {
124
+ f.operation_matches = canon(r.operation ?? null) === canon(want.operation);
125
+ if (!f.operation_matches)
126
+ why.push(r.operation ? "the receipt is for another operation (" + canon(r.operation).slice(0, 120) + ")" : "the receipt is not scoped to an operation, and this request named one");
127
+ }
128
+ if (want.nonce) {
129
+ f.nonce_matches = r.nonce === want.nonce;
130
+ if (!f.nonce_matches)
131
+ why.push(r.nonce ? "the receipt carries another request's nonce (replayed)" : "the receipt does not carry the nonce this request sent");
132
+ }
107
133
  const now = want.now ?? Date.now(), skew = Math.min(Math.max(Number(r.clock_skew_s) || 0, 0), MAX_CLOCK_SKEW_S) * 1000;
108
134
  const at = Date.parse(r.decided_at), exp = Date.parse(r.expires_at);
109
135
  if (!isFinite(at) || !isFinite(exp))
110
136
  why.push("decided_at or expires_at missing");
111
137
  else {
112
- if (now > exp + skew)
138
+ f.times_present = true;
139
+ f.not_expired = !(now > exp + skew);
140
+ if (!f.not_expired)
113
141
  why.push("expired at " + r.expires_at);
114
- if (at > now + skew)
142
+ f.not_future = !(at > now + skew);
143
+ if (!f.not_future)
115
144
  why.push("decided in the future (" + r.decided_at + "): check the clock");
116
145
  const max = ACTION_MAX_LIFETIME_S[String(want.action)];
117
146
  if (max === undefined)
118
147
  why.push("unknown action " + want.action);
119
- else if (exp - at > max * 1000 + 1000)
120
- why.push("valid for " + Math.round((exp - at) / 1000) + " s; a " + want.action + " receipt may last at most " + max + " s");
148
+ else {
149
+ f.lifetime_ok = !(exp - at > max * 1000 + 1000);
150
+ if (!f.lifetime_ok)
151
+ why.push("valid for " + Math.round((exp - at) / 1000) + " s; a " + want.action + " receipt may last at most " + max + " s");
152
+ }
121
153
  }
122
- if (DECISIONS.indexOf(r.decision) < 0)
154
+ f.decision_known = DECISIONS.indexOf(r.decision) >= 0;
155
+ if (!f.decision_known)
123
156
  why.push("unknown decision " + r.decision);
124
- if (!r.resolve || !/^[0-9a-f]{64}$/.test(String(r.resolve.sha256 || "")))
157
+ f.resolve_bound = !!(r.resolve && /^[0-9a-f]{64}$/.test(String(r.resolve.sha256 || "")));
158
+ if (!f.resolve_bound)
125
159
  why.push("the receipt names no resolve answer");
126
- return { ok: why.length === 0, why };
160
+ return { ok: why.length === 0, why, fields: f };
127
161
  }
128
162
  export const LOG_ID = L.LOG_ID;
129
163
  /** A new, empty transparency-log store (plain JSON: persist it between runs). */
@@ -347,7 +381,8 @@ export class CrawlCheck {
347
381
  */
348
382
  async guard(domain, action = "read", opts = {}) {
349
383
  const nonce = opts.nonce || randomNonce();
350
- const no = (why, receipt = null, verification = null) => ({ proceed: false, receipt, verification, header: null, why });
384
+ let scope = null;
385
+ const no = (why, receipt = null, verification = null) => ({ proceed: false, receipt, verification, header: null, why, acceptance: receipt && scope ? acceptanceOf(receipt, verification, scope, { allowWarn: !!opts.allowWarn }) : null });
351
386
  const rv = revokedFor(this.revocations, normalizeDomain(domain) || domain, action); // rvd1: refused offline, before any request
352
387
  if (rv)
353
388
  return Object.assign(no([rv.domain + " is on CrawlCheck's signed revocation list for " + action + " (" + (rv.causes.join(", ") || rv.state || "revoked") + (rv.since ? ", since " + rv.since : "") + "; list of " + this.revocations.generated_at + ")"]), { revoked: rv });
@@ -360,7 +395,7 @@ export class CrawlCheck {
360
395
  }
361
396
  if (this.log.forks.length)
362
397
  return Object.assign(no(["the transparency log has shown this client two histories (split view)"], receipt), { split_view: this.log.forks.slice() });
363
- const scope = checkDecision(receipt, { domain, action, nonce, now: opts.now, operation: opts.operation });
398
+ scope = checkDecision(receipt, { domain, action, nonce, now: opts.now, operation: opts.operation });
364
399
  if (!receipt || typeof receipt !== "object")
365
400
  return no(scope.why);
366
401
  const online = opts.onlineKeys ?? true;
@@ -386,20 +421,21 @@ export class CrawlCheck {
386
421
  if (why.length)
387
422
  return no(why, receipt, verification);
388
423
  const d = receipt.decision;
389
- let proceed = d === "allow" || (d === "warn" && !!opts.allowWarn);
424
+ let proceed = d === "allow" || (d === "warn" && !!opts.allowWarn), confirmed = null;
390
425
  if (d === "require_confirmation") {
391
426
  if (opts.confirm) {
392
427
  try {
393
- proceed = (await opts.confirm(receipt)) === true;
428
+ proceed = confirmed = (await opts.confirm(receipt)) === true;
394
429
  }
395
430
  catch {
396
- proceed = false;
431
+ proceed = confirmed = false;
397
432
  }
398
433
  }
399
434
  }
400
435
  if (!proceed)
401
436
  why.push(d === "warn" ? "decision is warn and allowWarn is not set" : d === "require_confirmation" ? (opts.confirm ? "a human did not confirm" : "decision is require_confirmation and no confirm() was given") : "decision is " + d);
402
- return { proceed, receipt, verification, header: proceed ? receiptHeader(receipt) : null, why };
437
+ const acceptance = acceptanceOf(receipt, verification, scope, { allowWarn: !!opts.allowWarn, confirmed });
438
+ return { proceed: proceed && acceptance.accepted, receipt, verification, header: proceed && acceptance.accepted ? receiptHeader(receipt) : null, why, acceptance };
403
439
  }
404
440
  /**
405
441
  * fetch() with the check where the action runs: guard() before the first request, and again before every redirect hop
@@ -692,4 +728,18 @@ export function toolsFor(format, tools) {
692
728
  return { tools: tools.map((t) => ({ name: t.name, description: t.description, inputSchema: t.parameters, annotations: { readOnlyHint: true, openWorldHint: true } })), call };
693
729
  return { tools: Object.fromEntries(tools.map((t) => [t.name, { description: t.description, parameters: t.parameters, execute: t.execute }])), call };
694
730
  }
731
+ /** Vercel AI SDK adapter (1.10.0): `import * as ai from "ai"; const tools = vercelTools(cc, ai);` then generateText({ tools, ... }).
732
+ * Same three tools as agentTools(); they fail closed. The schema is given as both inputSchema (AI SDK 5+) and parameters (AI SDK 4). */
733
+ export function vercelTools(cc, ai, opts = {}) {
734
+ if (!ai || typeof ai.tool !== "function" || typeof ai.jsonSchema !== "function")
735
+ throw new Error('vercelTools needs the "ai" module: import * as ai from "ai"');
736
+ return Object.fromEntries(agentTools(cc, opts).map((t) => { const sc = ai.jsonSchema(t.parameters); return [t.name, ai.tool({ description: t.description, inputSchema: sc, parameters: sc, execute: (args) => t.execute(args) })]; }));
737
+ }
738
+ /** LangChain.js adapter (1.10.0): `import * as lc from "@langchain/core/tools"; const tools = langchainTools(cc, lc);`
739
+ * Same three tools as agentTools(); each returns its result as a JSON string; they fail closed. */
740
+ export function langchainTools(cc, lc, opts = {}) {
741
+ if (!lc || typeof lc.tool !== "function")
742
+ throw new Error('langchainTools needs "@langchain/core/tools": import * as lc from "@langchain/core/tools"');
743
+ 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 }));
744
+ }
695
745
  export default CrawlCheck;
package/dist/mcp.d.ts CHANGED
@@ -1,11 +1,19 @@
1
+ export declare class McpPreflightRefused extends Error {
2
+ stage: any;
3
+ why: any;
4
+ receipt: any;
5
+ lock: any;
6
+ verification: any;
7
+ constructor(stage: any, why: any, detail: any);
8
+ }
1
9
  /** The server URL a transport will talk to, if it can be read (Streamable HTTP and SSE transports keep it as _url or url). */
2
- export function transportUrl(transport: any): string | null;
10
+ export declare function transportUrl(transport: any): string | null;
3
11
  /**
4
12
  * The two hooks. cc is a CrawlCheck client (from this SDK). opts: template (default safe_mcp_tool), policy, agent,
5
13
  * confirm(receipt) for require_confirmation, allowWarn, lock (an approved McpLock), confirmDestructive, maxAgeMs for a
6
14
  * cached connect receipt (default: until the receipt's expires_at).
7
15
  */
8
- export function mcpPreflight(cc: any, opts?: {}): {
16
+ export declare function mcpPreflight(cc: any, opts?: {}): {
9
17
  state: {
10
18
  url: null;
11
19
  host: null;
@@ -14,6 +22,9 @@ export function mcpPreflight(cc: any, opts?: {}): {
14
22
  decidedAt: number;
15
23
  lockCheck: null;
16
24
  approved: null;
25
+ continuity: null;
26
+ records: never[];
27
+ pausedTools: null;
17
28
  };
18
29
  /** Before initialize. url: the server URL (a stdio/local server has none and is not decided; pass opts.local = "allow" | "refuse", default allow). */
19
30
  beforeConnect(url: any): Promise<{
@@ -28,7 +39,7 @@ export function mcpPreflight(cc: any, opts?: {}): {
28
39
  verification: any;
29
40
  }>;
30
41
  /** After initialize, with the tools the server lists. Refuses (and the wrapper closes the client) when they differ from the approved lockfile. */
31
- afterConnect(tools: any): Promise<import("./index.js").McpLockCheck | null>;
42
+ afterConnect(tools: any, meta: any): Promise<any>;
32
43
  /** Before tools/call. */
33
44
  beforeToolCall(name: any, annotations: any, tool: any): Promise<{
34
45
  receipt: any;
@@ -40,12 +51,4 @@ export function mcpPreflight(cc: any, opts?: {}): {
40
51
  * client.preflight (the hooks and their state). Works on @modelcontextprotocol/sdk's Client and on anything with the same
41
52
  * two methods; listTools() is used for the lockfile check when present.
42
53
  */
43
- export function withPreflight(client: any, cc: any, opts?: {}): any;
44
- export class McpPreflightRefused extends Error {
45
- constructor(stage: any, why: any, detail: any);
46
- stage: any;
47
- why: any;
48
- receipt: any;
49
- lock: any;
50
- verification: any;
51
- }
54
+ export declare function withPreflight(client: any, cc: any, opts?: {}): any;
package/dist/mcp.js CHANGED
@@ -17,6 +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
21
  export class McpPreflightRefused extends Error {
21
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; }
22
23
  }
@@ -49,7 +50,18 @@ function destructive(ann) { if (!ann || typeof ann !== "object")
49
50
  */
50
51
  export function mcpPreflight(cc, opts = {}) {
51
52
  const template = opts.template || "safe_mcp_tool";
52
- const state = { url: null, host: null, receipt: null, verification: null, decidedAt: 0, lockCheck: null, approved: null };
53
+ const state = { url: null, host: null, receipt: null, verification: null, decidedAt: 0, lockCheck: null, approved: null, continuity: null, records: [], pausedTools: null };
54
+ // 1.11.0 continuity: opts.continuity = { mode: observe | review | enforce, policy, exceptions, onRecord(record) }. Every check
55
+ // (connect decision, tool list, failure) leaves a crawlcheck-continuity-record in state.records and is passed to onRecord.
56
+ const CO = opts.continuity || null;
57
+ const keep = async function (rec) { state.continuity = rec; state.records.push(rec); if (state.records.length > 50)
58
+ state.records.shift(); if (CO && typeof CO.onRecord === "function") {
59
+ try {
60
+ await CO.onRecord(rec);
61
+ }
62
+ catch (e) { }
63
+ } return rec; };
64
+ const coOpts = function (extra) { return Object.assign({ mode: CO.mode, policy: CO.policy, exceptions: CO.exceptions, server: state.url }, extra || {}); };
53
65
  const fresh = function () {
54
66
  if (!state.receipt)
55
67
  return false;
@@ -69,6 +81,23 @@ export function mcpPreflight(cc, opts = {}) {
69
81
  state.receipt = g.receipt;
70
82
  state.verification = g.verification;
71
83
  state.decidedAt = Date.now();
84
+ if (!g.proceed && CO) {
85
+ const rec = await keep(await continuityFailure(continuityClassOf(g), coOpts({ detail: (g.why || []).join("; ").slice(0, 300) })));
86
+ if (rec.outcome === "proceed")
87
+ return g; // observe mode: recorded, not enforced
88
+ if (rec.outcome === "pause" && opts.confirm) {
89
+ let ok = false;
90
+ try {
91
+ ok = (await opts.confirm(Object.assign({}, g.receipt || {}, { continuity: rec }))) === true;
92
+ }
93
+ catch (e) {
94
+ ok = false;
95
+ }
96
+ if (ok)
97
+ return g;
98
+ }
99
+ throw new McpPreflightRefused("connect", rec.why.join("; "), { receipt: g.receipt, verification: g.verification, lock: rec });
100
+ }
72
101
  if (!g.proceed) {
73
102
  const d = g.receipt && g.receipt.decision;
74
103
  const decisionOnly = (g.why || []).every(function (w) { return /^decision is|a human did not confirm|no confirm\(\) was given|allowWarn is not set/.test(w); });
@@ -97,7 +126,30 @@ export function mcpPreflight(cc, opts = {}) {
97
126
  return { local: false, host, receipt: g.receipt, verification: g.verification };
98
127
  },
99
128
  /** After initialize, with the tools the server lists. Refuses (and the wrapper closes the client) when they differ from the approved lockfile. */
100
- async afterConnect(tools) {
129
+ async afterConnect(tools, meta) {
130
+ if (CO && opts.lock) { // 1.11.0: classify, apply the operator's policy, keep the record
131
+ const rec = await keep(await continuityCheck(opts.lock, tools, coOpts({ complete: !(meta && meta.nextCursor) })));
132
+ state.lockCheck = rec;
133
+ state.approved = null;
134
+ state.pausedTools = new Set(rec.paused_tools);
135
+ if (rec.outcome === "deny")
136
+ throw new McpPreflightRefused("connect", rec.why.join("; "), { lock: rec, receipt: state.receipt });
137
+ if (rec.outcome === "pause" || rec.outcome === "refresh") {
138
+ let ok = false;
139
+ if (opts.confirm) {
140
+ try {
141
+ ok = (await opts.confirm(Object.assign({}, state.receipt || {}, { continuity: rec }))) === true;
142
+ }
143
+ catch (e) {
144
+ ok = false;
145
+ }
146
+ }
147
+ if (!ok)
148
+ throw new McpPreflightRefused("connect", rec.why.join("; "), { lock: rec, receipt: state.receipt });
149
+ state.pausedTools = new Set();
150
+ }
151
+ return rec;
152
+ }
101
153
  if (!opts.lock) {
102
154
  state.approved = null;
103
155
  return null;
@@ -113,6 +165,20 @@ export function mcpPreflight(cc, opts = {}) {
113
165
  async beforeToolCall(name, annotations, tool) {
114
166
  if (state.host && !fresh())
115
167
  await decide(state.host);
168
+ if (state.pausedTools && state.pausedTools.has(name)) { // 1.11.0: a changed or new tool waits for a person; the others stay usable
169
+ let ok = false;
170
+ if (opts.confirm) {
171
+ try {
172
+ ok = (await opts.confirm(Object.assign({}, state.receipt || {}, { tool: name, continuity: state.continuity }))) === true;
173
+ }
174
+ catch (e) {
175
+ ok = false;
176
+ }
177
+ }
178
+ if (!ok)
179
+ throw new McpPreflightRefused("tools/call", "tool " + name + " changed since it was approved (or is new) and no person re-approved it", { lock: state.continuity, receipt: state.receipt });
180
+ state.pausedTools.delete(name);
181
+ }
116
182
  if (state.approved && !state.approved.has(name))
117
183
  throw new McpPreflightRefused("tools/call", "tool " + name + " is not in the approved lockfile", { lock: state.lockCheck, receipt: state.receipt });
118
184
  if (opts.confirmDestructive && destructive(annotations)) {
@@ -147,18 +213,27 @@ export function withPreflight(client, cc, opts = {}) {
147
213
  const r = await connect(transport, ...rest);
148
214
  if (opts.lock || opts.confirmDestructive || (opts.toolReceipts || "write") !== "none") {
149
215
  let list = [];
216
+ let meta = null, bad = false;
150
217
  try {
151
218
  const t = typeof client.listTools === "function" ? await client.listTools() : null;
152
219
  list = (t && t.tools) || [];
220
+ meta = t ? { nextCursor: t.nextCursor || null } : null;
221
+ if (t && !Array.isArray(t.tools))
222
+ bad = true;
153
223
  }
154
224
  catch (e) {
155
225
  list = [];
226
+ bad = true;
227
+ }
228
+ if (!Array.isArray(list)) {
229
+ list = [];
230
+ bad = true;
156
231
  }
157
232
  list.forEach(function (t) { if (t && t.name)
158
233
  tools[t.name] = t; });
159
234
  if (opts.lock || opts.confirmDestructive)
160
235
  try {
161
- await pf.afterConnect(list);
236
+ await pf.afterConnect(bad && opts.continuity ? null : list, meta);
162
237
  }
163
238
  catch (e) {
164
239
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crawlcheck/sdk",
3
- "version": "1.9.2",
3
+ "version": "1.11.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",