@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/README.md +46 -5
- package/dist/continuity.d.ts +105 -0
- package/dist/continuity.js +284 -0
- package/dist/index.d.ts +177 -4
- package/dist/index.js +76 -26
- package/dist/mcp.d.ts +15 -12
- package/dist/mcp.js +78 -3
- package/package.json +1 -1
- package/test/continuity.test.js +81 -0
- package/test/fixtures/continuity-gen.mjs +68 -0
- package/test/fixtures/continuity.json +4911 -0
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[]
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
120
|
-
|
|
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
|
-
|
|
154
|
+
f.decision_known = DECISIONS.indexOf(r.decision) >= 0;
|
|
155
|
+
if (!f.decision_known)
|
|
123
156
|
why.push("unknown decision " + r.decision);
|
|
124
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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<
|
|
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.
|
|
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",
|