@crawlcheck/sdk 1.10.0 → 1.12.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 +58 -1
- package/dist/continuity.d.ts +105 -0
- package/dist/continuity.js +284 -0
- package/dist/frameworks.d.ts +39 -0
- package/dist/frameworks.js +224 -0
- package/dist/index.d.ts +210 -4
- package/dist/index.js +71 -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/test/fixtures/stub-mcp-server.mjs +19 -0
- package/test/frameworks.test.js +118 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// Tool-interface continuity for agent frameworks (1.12.0, https://crawlcheck.io/spec/continuity). The same contract as the
|
|
2
|
+
// Python package's continuity_gate.py and its openai_agents / langchain / crewai adapters.
|
|
3
|
+
//
|
|
4
|
+
// The check runs where the framework fetches an MCP server's tool list, against the lockfile the operator approved:
|
|
5
|
+
// observe every difference is recorded (gate.records, onRecord, console.warn unless log: false) and the tools pass
|
|
6
|
+
// through unchanged. Nothing is blocked.
|
|
7
|
+
// review any difference pauses the whole server until confirm() says yes; integrity failures are denied.
|
|
8
|
+
// enforce the policy applies: by default a changed or new tool is withheld from the agent (pause_tool) until confirm()
|
|
9
|
+
// re-approves it; a whole-server pause or refresh asks confirm() once; deny throws ContinuityRefused.
|
|
10
|
+
// confirm(request) may return a promise; request is { continuity: record } for the server or { tool, continuity } for one
|
|
11
|
+
// tool. A yes is remembered for that exact definition, so a framework that lists tools every turn does not ask twice.
|
|
12
|
+
// No framework is a dependency of this package: each adapter imports its framework only when called.
|
|
13
|
+
import { continuityCheck, continuityFailure } from "./continuity.js";
|
|
14
|
+
import { canon } from "./verify.js";
|
|
15
|
+
export class ContinuityRefused extends Error {
|
|
16
|
+
constructor(why, record = null, tool = null) {
|
|
17
|
+
super("CrawlCheck continuity refused" + (tool ? " tool " + tool : "") + ": " + why);
|
|
18
|
+
this.name = "ContinuityRefused";
|
|
19
|
+
this.why = why;
|
|
20
|
+
this.record = record;
|
|
21
|
+
this.tool = tool;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const pick = (t) => ({ name: t?.name, description: t?.description, inputSchema: t?.inputSchema ?? t?.input_schema, annotations: t?.annotations });
|
|
25
|
+
/** The shared gate. lock: an approved mcp-lockfile (with lock.parts so a change can be named). */
|
|
26
|
+
export function continuityGate(lock, opts = {}) {
|
|
27
|
+
const mode = opts.mode || "observe";
|
|
28
|
+
if (["observe", "review", "enforce"].indexOf(mode) < 0)
|
|
29
|
+
throw new Error("mode must be observe, review or enforce");
|
|
30
|
+
if (!lock || !Array.isArray(lock.tools))
|
|
31
|
+
throw new Error("lock must be an approved mcp-lockfile (an object with a tools array); see https://crawlcheck.io/spec/mcp-lockfile");
|
|
32
|
+
const keep = opts.keep ?? 50, okTools = new Set(), okLists = new Set();
|
|
33
|
+
let lastKey = null;
|
|
34
|
+
const co = { mode, policy: opts.policy, exceptions: opts.exceptions, server: opts.server ?? undefined, serverIdentity: opts.serverIdentity, expectPolicySha256: opts.expectPolicySha256 };
|
|
35
|
+
const gate = {
|
|
36
|
+
mode, records: [], record: null, paused: new Set(),
|
|
37
|
+
async keepRecord(rec) {
|
|
38
|
+
gate.record = rec;
|
|
39
|
+
gate.records = gate.records.concat([rec]).slice(-keep);
|
|
40
|
+
const changed = rec.events.some((e) => e.class !== "unchanged");
|
|
41
|
+
if (changed && opts.log !== false)
|
|
42
|
+
(opts.logger || console).warn("crawlcheck continuity " + rec.mode + " " + (rec.server || "-") + ": would " + rec.would + ", outcome " + rec.outcome + ": " + rec.why.join("; "));
|
|
43
|
+
if (opts.onRecord) {
|
|
44
|
+
try {
|
|
45
|
+
await opts.onRecord(rec);
|
|
46
|
+
}
|
|
47
|
+
catch (e) { /* recording never breaks the agent */ }
|
|
48
|
+
}
|
|
49
|
+
return rec;
|
|
50
|
+
},
|
|
51
|
+
async ask(req) {
|
|
52
|
+
if (!opts.confirm)
|
|
53
|
+
return false;
|
|
54
|
+
try {
|
|
55
|
+
return !!(await opts.confirm(req));
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
/** Compare the offered tools with the lock and apply the mode. Returns the record; throws ContinuityRefused. */
|
|
62
|
+
async check(tools, meta = {}) {
|
|
63
|
+
const complete = meta.complete !== false;
|
|
64
|
+
const rows = Array.isArray(tools) ? tools.map(pick) : tools;
|
|
65
|
+
let rec = await continuityCheck(lock, rows, Object.assign({}, co, { complete }));
|
|
66
|
+
const key = (rec.observed?.tools_sha256 ?? "") + "|" + complete;
|
|
67
|
+
if (lastKey === key && gate.record)
|
|
68
|
+
rec = gate.record;
|
|
69
|
+
else {
|
|
70
|
+
lastKey = key;
|
|
71
|
+
await gate.keepRecord(rec);
|
|
72
|
+
}
|
|
73
|
+
if (mode === "observe") {
|
|
74
|
+
gate.paused = new Set();
|
|
75
|
+
return rec;
|
|
76
|
+
}
|
|
77
|
+
if (rec.outcome === "deny")
|
|
78
|
+
throw new ContinuityRefused(rec.why.join("; "), rec);
|
|
79
|
+
if (rec.outcome === "pause" || rec.outcome === "refresh") {
|
|
80
|
+
if (!okLists.has(key)) {
|
|
81
|
+
if (!(await gate.ask({ continuity: rec })))
|
|
82
|
+
throw new ContinuityRefused(rec.why.join("; "), rec);
|
|
83
|
+
okLists.add(key);
|
|
84
|
+
}
|
|
85
|
+
gate.paused = new Set();
|
|
86
|
+
return rec;
|
|
87
|
+
}
|
|
88
|
+
const byName = {};
|
|
89
|
+
for (const r of Array.isArray(rows) ? rows : [])
|
|
90
|
+
byName[String(r.name ?? "").slice(0, 120)] = canon(r);
|
|
91
|
+
const paused = new Set();
|
|
92
|
+
for (const n of rec.paused_tools) {
|
|
93
|
+
const k = n + "\n" + (byName[n] ?? "");
|
|
94
|
+
if (okTools.has(k) || (await gate.ask({ tool: n, continuity: rec })))
|
|
95
|
+
okTools.add(k);
|
|
96
|
+
else
|
|
97
|
+
paused.add(n);
|
|
98
|
+
}
|
|
99
|
+
gate.paused = paused;
|
|
100
|
+
return rec;
|
|
101
|
+
},
|
|
102
|
+
/** Record a check that could not compare anything. Throws ContinuityRefused outside observe. */
|
|
103
|
+
async fail(cls, detail) {
|
|
104
|
+
const rec = await continuityFailure(cls, Object.assign({}, co, { detail: detail ? String(detail).slice(0, 300) : undefined }));
|
|
105
|
+
lastKey = null;
|
|
106
|
+
await gate.keepRecord(rec);
|
|
107
|
+
if (mode !== "observe" && rec.outcome !== "proceed")
|
|
108
|
+
throw new ContinuityRefused(rec.why.join("; "), rec);
|
|
109
|
+
return rec;
|
|
110
|
+
},
|
|
111
|
+
/** The tools the agent may see: all of them in observe mode, else without the paused ones. */
|
|
112
|
+
allowed(tools, nameOf = (t) => t?.name) { return mode === "observe" || !gate.paused.size ? tools.slice() : tools.filter((t) => !gate.paused.has(nameOf(t))); },
|
|
113
|
+
/** Refuse a call to a paused tool (a framework that cached an earlier list could still try it). */
|
|
114
|
+
callOk(name) { if (mode !== "observe" && gate.paused.has(name))
|
|
115
|
+
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
|
+
};
|
|
117
|
+
return gate;
|
|
118
|
+
}
|
|
119
|
+
const urlOf = (s) => {
|
|
120
|
+
for (const p of [s && s.params, s && s.underlying && s.underlying.params, s && s.options])
|
|
121
|
+
if (p && typeof p.url === "string" && p.url)
|
|
122
|
+
return p.url;
|
|
123
|
+
return null;
|
|
124
|
+
};
|
|
125
|
+
/**
|
|
126
|
+
* OpenAI Agents SDK (@openai/agents): wrap an MCP server (MCPServerStdio, MCPServerStreamableHttp, MCPServerSSE or your
|
|
127
|
+
* own MCPServer) so every listTools() the SDK makes is checked against lock. Everything else is the server's own.
|
|
128
|
+
* const guarded = withContinuity(server, lock, { mode: "observe" }); new Agent({ name, instructions, mcpServers: [guarded] })
|
|
129
|
+
* The records are at guarded.continuity.records.
|
|
130
|
+
*/
|
|
131
|
+
export function withContinuity(server, lock, opts = {}) {
|
|
132
|
+
const gate = opts.gate || continuityGate(lock, Object.assign({ server: opts.serverUrl ?? urlOf(server) ?? undefined }, opts));
|
|
133
|
+
const listTools = async (...a) => {
|
|
134
|
+
let tools;
|
|
135
|
+
try {
|
|
136
|
+
tools = await server.listTools(...a);
|
|
137
|
+
}
|
|
138
|
+
catch (e) {
|
|
139
|
+
await gate.fail("server_unavailable", e && e.message);
|
|
140
|
+
throw e;
|
|
141
|
+
}
|
|
142
|
+
await gate.check(tools, { complete: true }); // the Agents SDK reads every page or throws
|
|
143
|
+
return gate.allowed(tools);
|
|
144
|
+
};
|
|
145
|
+
const callTool = async (name, ...a) => { gate.callOk(name); return server.callTool(name, ...a); };
|
|
146
|
+
const callToolResult = server.callToolResult ? async (name, ...a) => { gate.callOk(name); return server.callToolResult(name, ...a); } : undefined;
|
|
147
|
+
return new Proxy(server, {
|
|
148
|
+
get(target, k) {
|
|
149
|
+
if (k === "listTools")
|
|
150
|
+
return listTools;
|
|
151
|
+
if (k === "callTool")
|
|
152
|
+
return callTool;
|
|
153
|
+
if (k === "callToolResult" && callToolResult)
|
|
154
|
+
return callToolResult;
|
|
155
|
+
if (k === "continuity")
|
|
156
|
+
return gate;
|
|
157
|
+
const v = Reflect.get(target, k, target);
|
|
158
|
+
return typeof v === "function" ? v.bind(target) : v; // bound: the servers keep private (#) state
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
async function listAll(client) {
|
|
163
|
+
const out = [];
|
|
164
|
+
let cursor, pages = 0;
|
|
165
|
+
for (;;) {
|
|
166
|
+
const r = await client.listTools(cursor ? { cursor } : undefined);
|
|
167
|
+
out.push(...(r?.tools ?? []));
|
|
168
|
+
const next = r?.nextCursor;
|
|
169
|
+
if (!next)
|
|
170
|
+
return { tools: out, complete: true };
|
|
171
|
+
if (++pages >= 1000 || next === cursor)
|
|
172
|
+
return { tools: out, complete: false };
|
|
173
|
+
cursor = next;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* LangChain (@langchain/mcp-adapters): loadMcpTools with the continuity check.
|
|
178
|
+
* const tools = await loadMcpToolsWithContinuity("shop", client, lock, { mode: "observe" })
|
|
179
|
+
* client: a MultiServerMCPClient (its connection for serverName is used) or a connected MCP SDK Client. The list is read
|
|
180
|
+
* once (every page) and the LangChain tools are converted from exactly the definitions that were compared.
|
|
181
|
+
* opts.adapters: the @langchain/mcp-adapters module (default: imported on first use); opts.loadOptions: its
|
|
182
|
+
* LoadMcpToolsOptions. The gate (records) is opts.gate, or read them from opts.onRecord.
|
|
183
|
+
*/
|
|
184
|
+
export async function loadMcpToolsWithContinuity(serverName, client, lock, opts = {}) {
|
|
185
|
+
const lc = opts.adapters || (await import("@langchain/mcp-adapters"));
|
|
186
|
+
let mcp = client;
|
|
187
|
+
if (client && typeof client.getClient === "function" && typeof client.callTool !== "function") {
|
|
188
|
+
mcp = await client.getClient(serverName);
|
|
189
|
+
if (!mcp)
|
|
190
|
+
throw new Error("no connected MCP server named " + serverName);
|
|
191
|
+
}
|
|
192
|
+
let conn = null;
|
|
193
|
+
try {
|
|
194
|
+
const c = client && client !== mcp ? client.config : null;
|
|
195
|
+
conn = c ? ((c.servers || c.mcpServers || {})[serverName] ?? null) : null;
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
conn = null;
|
|
199
|
+
}
|
|
200
|
+
const gate = opts.gate || continuityGate(lock, Object.assign({ server: opts.serverUrl ?? (conn && conn.url) ?? undefined }, opts));
|
|
201
|
+
let listed;
|
|
202
|
+
try {
|
|
203
|
+
listed = await listAll(mcp);
|
|
204
|
+
}
|
|
205
|
+
catch (e) {
|
|
206
|
+
await gate.fail("server_unavailable", e && e.message);
|
|
207
|
+
throw e;
|
|
208
|
+
}
|
|
209
|
+
await gate.check(structuredClone(listed.tools), { complete: listed.complete });
|
|
210
|
+
const allowed = gate.allowed(listed.tools);
|
|
211
|
+
const pinned = new Proxy(mcp, {
|
|
212
|
+
get(target, k) {
|
|
213
|
+
if (k === "listTools")
|
|
214
|
+
return async () => ({ tools: allowed });
|
|
215
|
+
if (k === "callTool")
|
|
216
|
+
return async (params, ...a) => { gate.callOk(params && params.name); return target.callTool(params, ...a); };
|
|
217
|
+
const v = Reflect.get(target, k, target);
|
|
218
|
+
return typeof v === "function" ? v.bind(target) : v;
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
const tools = await lc.loadMcpTools(serverName, pinned, opts.loadOptions);
|
|
222
|
+
Object.defineProperty(tools, "continuity", { value: gate, enumerable: false });
|
|
223
|
+
return tools;
|
|
224
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,183 @@
|
|
|
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;
|
|
129
|
+
export interface ContinuityGateOptions extends ContinuityOptions {
|
|
130
|
+
/** A person's re-approval: { continuity } for the server, { tool, continuity } for one tool. Remembered per definition. */
|
|
131
|
+
confirm?: (request: {
|
|
132
|
+
tool?: string;
|
|
133
|
+
continuity: ContinuityRecord;
|
|
134
|
+
}) => boolean | Promise<boolean>;
|
|
135
|
+
onRecord?: (record: ContinuityRecord) => unknown;
|
|
136
|
+
/** false: no console.warn on a change. */ log?: boolean;
|
|
137
|
+
logger?: {
|
|
138
|
+
warn: (msg: string) => unknown;
|
|
139
|
+
};
|
|
140
|
+
/** Records kept in gate.records (default 50). */ keep?: number;
|
|
141
|
+
}
|
|
142
|
+
export interface ContinuityGate {
|
|
143
|
+
mode: "observe" | "review" | "enforce";
|
|
144
|
+
records: ContinuityRecord[];
|
|
145
|
+
record: ContinuityRecord | null;
|
|
146
|
+
paused: Set<string>;
|
|
147
|
+
check(tools: McpTool[], meta?: {
|
|
148
|
+
complete?: boolean;
|
|
149
|
+
}): Promise<ContinuityRecord>;
|
|
150
|
+
fail(cls: string, detail?: string): Promise<ContinuityRecord>;
|
|
151
|
+
allowed<T>(tools: T[], nameOf?: (t: T) => string): T[];
|
|
152
|
+
callOk(name: string): true;
|
|
153
|
+
}
|
|
154
|
+
/** Thrown in review/enforce when a record's outcome is pause, refresh or deny and nobody re-approved (or a paused tool is called). */
|
|
155
|
+
export declare const ContinuityRefused: new (why: string, record?: ContinuityRecord | null, tool?: string | null) => Error & {
|
|
156
|
+
why: string;
|
|
157
|
+
record: ContinuityRecord | null;
|
|
158
|
+
tool: string | null;
|
|
159
|
+
};
|
|
160
|
+
/** The shared gate behind the framework adapters. Default mode observe. */
|
|
161
|
+
export declare const continuityGate: (lock: McpLock, opts?: ContinuityGateOptions) => ContinuityGate;
|
|
162
|
+
/** @openai/agents: wrap an MCP server so every listTools() is checked against lock. Records at server.continuity. */
|
|
163
|
+
export declare const withContinuity: <S extends {
|
|
164
|
+
listTools: (...a: any[]) => Promise<any[]>;
|
|
165
|
+
callTool: (...a: any[]) => Promise<any>;
|
|
166
|
+
}>(server: S, lock: McpLock, opts?: ContinuityGateOptions & {
|
|
167
|
+
serverUrl?: string;
|
|
168
|
+
gate?: ContinuityGate;
|
|
169
|
+
}) => S & {
|
|
170
|
+
continuity: ContinuityGate;
|
|
171
|
+
};
|
|
172
|
+
/** @langchain/mcp-adapters: loadMcpTools with the check. client: a MultiServerMCPClient or a connected MCP SDK Client. */
|
|
173
|
+
export declare const loadMcpToolsWithContinuity: (serverName: string, client: unknown, lock: McpLock, opts?: ContinuityGateOptions & {
|
|
174
|
+
serverUrl?: string;
|
|
175
|
+
gate?: ContinuityGate;
|
|
176
|
+
adapters?: unknown;
|
|
177
|
+
loadOptions?: unknown;
|
|
178
|
+
}) => Promise<any[] & {
|
|
179
|
+
continuity: ContinuityGate;
|
|
180
|
+
}>;
|
|
4
181
|
/** One verifier check. ok: true passed, false FAILED, null could not run (why says why) - never a pass. */
|
|
5
182
|
export interface VerifyCheck {
|
|
6
183
|
id: string;
|
|
@@ -180,7 +357,12 @@ export interface McpLock {
|
|
|
180
357
|
tools: {
|
|
181
358
|
name: string;
|
|
182
359
|
sha256: string;
|
|
183
|
-
}[];
|
|
360
|
+
}[]; /** 1.11.0: per-part hashes (lockParts) so continuityCheck can name what changed. */
|
|
361
|
+
parts?: Record<string, {
|
|
362
|
+
description_sha256: string;
|
|
363
|
+
schema_sha256: string;
|
|
364
|
+
annotations_sha256: string;
|
|
365
|
+
}>;
|
|
184
366
|
descriptions?: Record<string, string>;
|
|
185
367
|
server?: {
|
|
186
368
|
url?: string | null;
|
|
@@ -303,16 +485,31 @@ export interface DecisionWant {
|
|
|
303
485
|
* time window, per-action lifetime, known decision, resolve binding); the signature and issuer are checked by
|
|
304
486
|
* verifyDocument. guard() runs both. https://crawlcheck.io/spec/guard
|
|
305
487
|
*/
|
|
488
|
+
export interface DecisionScopeFields {
|
|
489
|
+
kind_ok: boolean;
|
|
490
|
+
domain_matches: boolean;
|
|
491
|
+
action_matches: boolean;
|
|
492
|
+
operation_matches?: boolean;
|
|
493
|
+
nonce_matches?: boolean;
|
|
494
|
+
times_present: boolean;
|
|
495
|
+
not_expired: boolean;
|
|
496
|
+
not_future: boolean;
|
|
497
|
+
lifetime_ok: boolean;
|
|
498
|
+
decision_known: boolean;
|
|
499
|
+
resolve_bound: boolean;
|
|
500
|
+
}
|
|
306
501
|
export declare function checkDecision(receipt: unknown, want: DecisionWant): {
|
|
307
502
|
ok: boolean;
|
|
308
503
|
why: string[];
|
|
504
|
+
fields: DecisionScopeFields;
|
|
309
505
|
};
|
|
310
506
|
export interface GuardResult {
|
|
311
507
|
proceed: boolean;
|
|
312
508
|
receipt: DecisionReceipt | null;
|
|
313
509
|
verification: (DocumentVerification & Trust) | null;
|
|
314
510
|
header: Record<string, string> | null;
|
|
315
|
-
why: string[];
|
|
511
|
+
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. */
|
|
512
|
+
acceptance: Acceptance | null;
|
|
316
513
|
split_view?: LogFork[]; /** Set when the domain was refused from the held revocation list, with no network call. */
|
|
317
514
|
revoked?: RevokedEntry;
|
|
318
515
|
}
|
|
@@ -528,6 +725,10 @@ export interface McpPreflightOptions extends PreflightOptions {
|
|
|
528
725
|
local?: "allow" | "refuse";
|
|
529
726
|
/** The server URL when the transport does not expose it. */
|
|
530
727
|
serverUrl?: string;
|
|
728
|
+
/** 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 & {
|
|
730
|
+
onRecord?: (record: ContinuityRecord) => unknown;
|
|
731
|
+
};
|
|
531
732
|
onlineKeys?: boolean;
|
|
532
733
|
}
|
|
533
734
|
export interface McpPreflightState {
|
|
@@ -536,8 +737,11 @@ export interface McpPreflightState {
|
|
|
536
737
|
receipt: DecisionReceipt | null;
|
|
537
738
|
verification: (DocumentVerification & Trust) | null;
|
|
538
739
|
decidedAt: number;
|
|
539
|
-
lockCheck: McpLockCheck | null;
|
|
740
|
+
lockCheck: McpLockCheck | ContinuityRecord | null;
|
|
540
741
|
approved: Set<string> | null;
|
|
742
|
+
continuity: ContinuityRecord | null;
|
|
743
|
+
records: ContinuityRecord[];
|
|
744
|
+
pausedTools: Set<string> | null;
|
|
541
745
|
}
|
|
542
746
|
export interface McpPreflightHooks {
|
|
543
747
|
state: McpPreflightState;
|
|
@@ -547,7 +751,9 @@ export interface McpPreflightHooks {
|
|
|
547
751
|
receipt: DecisionReceipt | null;
|
|
548
752
|
verification?: DocumentVerification & Trust;
|
|
549
753
|
}>;
|
|
550
|
-
afterConnect(tools: McpTool[]
|
|
754
|
+
afterConnect(tools: McpTool[] | null, meta?: {
|
|
755
|
+
nextCursor?: string | null;
|
|
756
|
+
} | null): Promise<McpLockCheck | ContinuityRecord | null>;
|
|
551
757
|
beforeToolCall(name: string, annotations?: unknown): Promise<{
|
|
552
758
|
receipt: DecisionReceipt | null;
|
|
553
759
|
}>;
|
package/dist/index.js
CHANGED
|
@@ -3,6 +3,32 @@ 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
|
+
import * as F from "./frameworks.js";
|
|
8
|
+
export const CONTINUITY_DEFAULT_POLICY = C.CONTINUITY_DEFAULT_POLICY;
|
|
9
|
+
export const CONTINUITY_CHANGE_CLASSES = C.CONTINUITY_CHANGE_CLASSES;
|
|
10
|
+
export const CONTINUITY_FAILURE_CLASSES = C.CONTINUITY_FAILURE_CLASSES;
|
|
11
|
+
/** Compare offered tools with the approved baseline and apply the operator's policy; returns the record to keep. */
|
|
12
|
+
export const continuityCheck = C.continuityCheck;
|
|
13
|
+
/** The record for a check that could not compare (server, evidence or service failed). */
|
|
14
|
+
export const continuityFailure = C.continuityFailure;
|
|
15
|
+
/** The policy in force (default + yours) and its sha256. */
|
|
16
|
+
export const continuityPolicy = C.continuityPolicy;
|
|
17
|
+
/** Per-part hashes (description, schema, annotations) so a change can be named. Store lockParts(tools) as lock.parts. */
|
|
18
|
+
export const lockToolParts = C.lockToolParts;
|
|
19
|
+
export const lockParts = C.lockParts;
|
|
20
|
+
/** The separate acceptance questions for one decision receipt. Act on policy_result, never on integrity_valid alone. */
|
|
21
|
+
/** Which continuity failure class a refused guard() result is. */
|
|
22
|
+
export const continuityClassOf = C.continuityClassOf;
|
|
23
|
+
export const acceptanceOf = C.acceptanceOf;
|
|
24
|
+
/** 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
|
+
export const ContinuityRefused = F.ContinuityRefused;
|
|
26
|
+
/** The shared gate behind the framework adapters. Default mode observe. */
|
|
27
|
+
export const continuityGate = F.continuityGate;
|
|
28
|
+
/** @openai/agents: wrap an MCP server so every listTools() is checked against lock. Records at server.continuity. */
|
|
29
|
+
export const withContinuity = F.withContinuity;
|
|
30
|
+
/** @langchain/mcp-adapters: loadMcpTools with the check. client: a MultiServerMCPClient or a connected MCP SDK Client. */
|
|
31
|
+
export const loadMcpToolsWithContinuity = F.loadMcpToolsWithContinuity;
|
|
6
32
|
/** The signing policy as data (https://crawlcheck.io/keys#policy): object purposes, purpose chains, start times. */
|
|
7
33
|
export const KEY_POLICY = V.KEY_POLICY;
|
|
8
34
|
/** Build the policy input from a key directory you hold and, optionally, the rotation record saved with it. */
|
|
@@ -84,46 +110,63 @@ export const ACTION_MAX_LIFETIME_S = { read: 86400, cite: 86400, connect: 3600,
|
|
|
84
110
|
/** The most clock skew a receipt may claim (seconds). */
|
|
85
111
|
export const MAX_CLOCK_SKEW_S = 300;
|
|
86
112
|
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
113
|
export function checkDecision(receipt, want) {
|
|
93
114
|
const why = [], r = receipt;
|
|
115
|
+
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 };
|
|
116
|
+
if (want.operation)
|
|
117
|
+
f.operation_matches = false;
|
|
118
|
+
if (want.nonce)
|
|
119
|
+
f.nonce_matches = false;
|
|
94
120
|
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
|
-
|
|
121
|
+
return { ok: false, why: ["no receipt: CrawlCheck sent " + (r === null || r === undefined ? "nothing" : typeof r)], fields: f };
|
|
122
|
+
f.kind_ok = r.kind === "crawlcheck-decision" && r.v === 1;
|
|
123
|
+
if (!f.kind_ok)
|
|
97
124
|
why.push("not a v1 decision receipt (kind " + r.kind + ", v " + r.v + ")");
|
|
98
125
|
const wd = normalizeDomain(want.domain);
|
|
99
|
-
|
|
126
|
+
f.domain_matches = !!wd && normalizeDomain(r.domain) === wd;
|
|
127
|
+
if (!f.domain_matches)
|
|
100
128
|
why.push("the receipt is for " + r.domain + ", not " + want.domain);
|
|
101
|
-
|
|
129
|
+
f.action_matches = String(r.action) === String(want.action);
|
|
130
|
+
if (!f.action_matches)
|
|
102
131
|
why.push("the receipt is for action " + r.action + ", not " + want.action);
|
|
103
|
-
if (want.operation
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
132
|
+
if (want.operation) {
|
|
133
|
+
f.operation_matches = canon(r.operation ?? null) === canon(want.operation);
|
|
134
|
+
if (!f.operation_matches)
|
|
135
|
+
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");
|
|
136
|
+
}
|
|
137
|
+
if (want.nonce) {
|
|
138
|
+
f.nonce_matches = r.nonce === want.nonce;
|
|
139
|
+
if (!f.nonce_matches)
|
|
140
|
+
why.push(r.nonce ? "the receipt carries another request's nonce (replayed)" : "the receipt does not carry the nonce this request sent");
|
|
141
|
+
}
|
|
107
142
|
const now = want.now ?? Date.now(), skew = Math.min(Math.max(Number(r.clock_skew_s) || 0, 0), MAX_CLOCK_SKEW_S) * 1000;
|
|
108
143
|
const at = Date.parse(r.decided_at), exp = Date.parse(r.expires_at);
|
|
109
144
|
if (!isFinite(at) || !isFinite(exp))
|
|
110
145
|
why.push("decided_at or expires_at missing");
|
|
111
146
|
else {
|
|
112
|
-
|
|
147
|
+
f.times_present = true;
|
|
148
|
+
f.not_expired = !(now > exp + skew);
|
|
149
|
+
if (!f.not_expired)
|
|
113
150
|
why.push("expired at " + r.expires_at);
|
|
114
|
-
|
|
151
|
+
f.not_future = !(at > now + skew);
|
|
152
|
+
if (!f.not_future)
|
|
115
153
|
why.push("decided in the future (" + r.decided_at + "): check the clock");
|
|
116
154
|
const max = ACTION_MAX_LIFETIME_S[String(want.action)];
|
|
117
155
|
if (max === undefined)
|
|
118
156
|
why.push("unknown action " + want.action);
|
|
119
|
-
else
|
|
120
|
-
|
|
157
|
+
else {
|
|
158
|
+
f.lifetime_ok = !(exp - at > max * 1000 + 1000);
|
|
159
|
+
if (!f.lifetime_ok)
|
|
160
|
+
why.push("valid for " + Math.round((exp - at) / 1000) + " s; a " + want.action + " receipt may last at most " + max + " s");
|
|
161
|
+
}
|
|
121
162
|
}
|
|
122
|
-
|
|
163
|
+
f.decision_known = DECISIONS.indexOf(r.decision) >= 0;
|
|
164
|
+
if (!f.decision_known)
|
|
123
165
|
why.push("unknown decision " + r.decision);
|
|
124
|
-
|
|
166
|
+
f.resolve_bound = !!(r.resolve && /^[0-9a-f]{64}$/.test(String(r.resolve.sha256 || "")));
|
|
167
|
+
if (!f.resolve_bound)
|
|
125
168
|
why.push("the receipt names no resolve answer");
|
|
126
|
-
return { ok: why.length === 0, why };
|
|
169
|
+
return { ok: why.length === 0, why, fields: f };
|
|
127
170
|
}
|
|
128
171
|
export const LOG_ID = L.LOG_ID;
|
|
129
172
|
/** A new, empty transparency-log store (plain JSON: persist it between runs). */
|
|
@@ -347,7 +390,8 @@ export class CrawlCheck {
|
|
|
347
390
|
*/
|
|
348
391
|
async guard(domain, action = "read", opts = {}) {
|
|
349
392
|
const nonce = opts.nonce || randomNonce();
|
|
350
|
-
|
|
393
|
+
let scope = null;
|
|
394
|
+
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
395
|
const rv = revokedFor(this.revocations, normalizeDomain(domain) || domain, action); // rvd1: refused offline, before any request
|
|
352
396
|
if (rv)
|
|
353
397
|
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 +404,7 @@ export class CrawlCheck {
|
|
|
360
404
|
}
|
|
361
405
|
if (this.log.forks.length)
|
|
362
406
|
return Object.assign(no(["the transparency log has shown this client two histories (split view)"], receipt), { split_view: this.log.forks.slice() });
|
|
363
|
-
|
|
407
|
+
scope = checkDecision(receipt, { domain, action, nonce, now: opts.now, operation: opts.operation });
|
|
364
408
|
if (!receipt || typeof receipt !== "object")
|
|
365
409
|
return no(scope.why);
|
|
366
410
|
const online = opts.onlineKeys ?? true;
|
|
@@ -386,20 +430,21 @@ export class CrawlCheck {
|
|
|
386
430
|
if (why.length)
|
|
387
431
|
return no(why, receipt, verification);
|
|
388
432
|
const d = receipt.decision;
|
|
389
|
-
let proceed = d === "allow" || (d === "warn" && !!opts.allowWarn);
|
|
433
|
+
let proceed = d === "allow" || (d === "warn" && !!opts.allowWarn), confirmed = null;
|
|
390
434
|
if (d === "require_confirmation") {
|
|
391
435
|
if (opts.confirm) {
|
|
392
436
|
try {
|
|
393
|
-
proceed = (await opts.confirm(receipt)) === true;
|
|
437
|
+
proceed = confirmed = (await opts.confirm(receipt)) === true;
|
|
394
438
|
}
|
|
395
439
|
catch {
|
|
396
|
-
proceed = false;
|
|
440
|
+
proceed = confirmed = false;
|
|
397
441
|
}
|
|
398
442
|
}
|
|
399
443
|
}
|
|
400
444
|
if (!proceed)
|
|
401
445
|
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
|
-
|
|
446
|
+
const acceptance = acceptanceOf(receipt, verification, scope, { allowWarn: !!opts.allowWarn, confirmed });
|
|
447
|
+
return { proceed: proceed && acceptance.accepted, receipt, verification, header: proceed && acceptance.accepted ? receiptHeader(receipt) : null, why, acceptance };
|
|
403
448
|
}
|
|
404
449
|
/**
|
|
405
450
|
* fetch() with the check where the action runs: guard() before the first request, and again before every redirect hop
|