@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.
@@ -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[]): Promise<McpLockCheck | null>;
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
- if (r.kind !== "crawlcheck-decision" || r.v !== 1)
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
- if (!wd || normalizeDomain(r.domain) !== wd)
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
- if (String(r.action) !== String(want.action))
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 && 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");
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
- if (now > exp + skew)
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
- if (at > now + skew)
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 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");
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
- if (DECISIONS.indexOf(r.decision) < 0)
163
+ f.decision_known = DECISIONS.indexOf(r.decision) >= 0;
164
+ if (!f.decision_known)
123
165
  why.push("unknown decision " + r.decision);
124
- if (!r.resolve || !/^[0-9a-f]{64}$/.test(String(r.resolve.sha256 || "")))
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
- const no = (why, receipt = null, verification = null) => ({ proceed: false, receipt, verification, header: null, why });
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
- const scope = checkDecision(receipt, { domain, action, nonce, now: opts.now, operation: opts.operation });
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
- return { proceed, receipt, verification, header: proceed ? receiptHeader(receipt) : null, why };
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