@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 CHANGED
@@ -16,6 +16,24 @@ const { proceed, receipt } = await cc.guard("example.com", "read", { agent: "MyA
16
16
  // The receipt is a signed crawlcheck-decision; guard() verified it here and checked its key against the published directory.
17
17
  ```
18
18
 
19
+ Since 1.11.0 `guard()` also returns `acceptance`: the separate questions a single "verified" used to answer at once, so nothing can mistake "signature valid" for "safe to proceed".
20
+
21
+ | field | the question |
22
+ |---|---|
23
+ | `integrity_valid` | are the bytes unchanged (hashes, signature, proof paths)? |
24
+ | `issuer_trusted` | is the signing key one CrawlCheck publishes? (`null` offline without pinned keys) |
25
+ | `key_policy_valid` | may that key sign this object at its signing time? |
26
+ | `freshness_valid` | inside its validity window, not dated in the future, no longer-lived than the action allows? |
27
+ | `subject_matches` | for this domain, this action, a known decision, bound to a resolve answer? |
28
+ | `operation_matches` / `replay_safe` | for the exact operation and nonce this request sent? (`null` when none was sent) |
29
+ | `baseline_matches` | do the tools match the approved baseline? (`null` when no baseline was given) |
30
+ | `assessment_complete` | did every check run, on a fully measured answer? |
31
+ | `policy_source` | which policy produced the decision (`crawlcheck_default`, `caller_policy`, `template:<name>`, `policy_file:<id>`) |
32
+ | `evidence_level` | `A` record integrity, `B` reproducible decision, `C` corroborated reading (the receipt's `evidence_assurance`) |
33
+ | `policy_result` | `proceed`, `pause` or `deny`; `accepted` is `policy_result === "proceed"` |
34
+
35
+ `acceptanceOf(receipt, verification, checkDecision(receipt, want))` computes the same split for a receipt you hold. A receipt is evidence and a recommendation: whether your agent acts is decided by your deployment's policy, and every receipt names the policy that produced it (`authority`). `POST https://crawlcheck.io/api/v1/preflight/replay` with a receipt re-runs the decision from the archived answer and the inputs the receipt carries; `GET /api/v1/preflight/rules` serves the rule tables that hash to `engine.rules_sha256`.
36
+
19
37
  Every verification returns `integrity_valid`, `issuer_trusted` and `accepted`. Act on `accepted`: it needs the signing key to be one CrawlCheck publishes, so a document someone signed with their own key is `verified` offline but never `accepted`. The client methods (`verifyDocument`, `verifyOffline`, `verifyReceiptOffline`, `guard`) check the published key directory for you.
20
38
 
21
39
  Since 1.9.1 a key the rotation record lists as retired still counts for its own signing window when the expected purpose is worked out: after a decision-key rotation, receipts the old decision key signed inside its window verify, and no other key may sign a decision made while that key was live (key-policy suite cases retired-decision-key-*).
@@ -120,7 +138,46 @@ try { await client.connect(new StreamableHTTPClientTransport(new URL(url))); }
120
138
  catch (e) { if (e instanceof McpPreflightRefused) console.log(e.stage, e.why, e.receipt.decision); else throw e; }
121
139
  ```
122
140
 
123
- `block`, `unsupported`, an unverifiable receipt, or `require_confirmation` with no yes refuse the connect before `initialize`. Other client stacks use the bare hooks: `mcpPreflight(cc, opts)` returns `beforeConnect`, `afterConnect` and `beforeToolCall`. A runnable demo is in `examples/mcp-preflight-demo.mjs`.
141
+ `block`, `unsupported`, an unverifiable receipt, or `require_confirmation` with no yes refuse the connect before `initialize`.
142
+
143
+ ### Tool-interface continuity (1.11.0)
144
+
145
+ The promise, and only this one: your agent does not silently proceed with a changed tool definition when your policy requires renewed approval ([spec](https://crawlcheck.io/spec/continuity)). Store per-part hashes with the lockfile (`lock.parts = await lockParts(tools)`) so a change is named, then pick a mode:
146
+
147
+ ```js
148
+ const client = withPreflight(new Client(...), new CrawlCheck(), {
149
+ lock: approvedLock, // tools, tools_sha256, parts, server.url, approved_at
150
+ confirm: async (r) => askTheUser(r), // r.continuity is the record; r.tool names a paused tool
151
+ continuity: { mode: "observe", onRecord: (rec) => log.write(rec) }, // then "review", then "enforce"
152
+ });
153
+ ```
154
+
155
+ | mode | what happens |
156
+ |---|---|
157
+ | `observe` | nothing is blocked; every check is recorded with what the policy *would* have done (`would`) |
158
+ | `review` | every change pauses the session for a person; integrity failures still deny |
159
+ | `enforce` | the policy applies: a changed or new tool is paused on its own (`pause_tool`) while the rest stay usable; integrity failures deny |
160
+
161
+ The default policy (`CONTINUITY_DEFAULT_POLICY`) pauses added, changed-description, changed-schema and changed-annotation tools, refreshes expired evidence, pauses on an unreachable server or service, a truncated or unreadable tool list, a stale baseline or an unexpected policy version, and denies on an invalid signature, an unpublished or retired key, or a different server. Override any effect; the integrity classes stay `deny` unless you set `allow_integrity_override`, and the record says so. Exceptions (`{ tool, class, until, by, reason }`) proceed as `proceed_recorded`, never as an ordinary pass. Every record (`crawlcheck-continuity-record`) names the baseline, what was observed, each event, the policy's sha256, the mode, the outcome and its own `record_sha256`. It states interface continuity at the check point: not code attestation, not a judgement that a tool is harmless, not user authorisation. `continuityCheck(lock, tools, opts)` and `continuityFailure(cls, opts)` run the same logic without a client; the Python SDK reproduces every record byte for byte (shared golden file `test/fixtures/continuity.json`). Other client stacks use the bare hooks: `mcpPreflight(cc, opts)` returns `beforeConnect`, `afterConnect` and `beforeToolCall`. A runnable demo is in `examples/mcp-preflight-demo.mjs`.
162
+
163
+ ### Continuity in your agent framework (1.12.0)
164
+
165
+ The same check where the framework fetches an MCP server's tool list, against your approved lockfile. No CrawlCheck call, no key, no new dependency: each adapter uses the framework you already have.
166
+
167
+ ```js
168
+ import { withContinuity, loadMcpToolsWithContinuity } from "@crawlcheck/sdk";
169
+
170
+ // OpenAI Agents SDK (@openai/agents)
171
+ const server = new MCPServerStreamableHttp({ url });
172
+ await server.connect();
173
+ const agent = new Agent({ name: "a", instructions: "...", mcpServers: [withContinuity(server, lock, { mode: "observe" })] });
174
+
175
+ // LangChain (@langchain/mcp-adapters)
176
+ const client = new MultiServerMCPClient({ mcpServers: { shop: { url } } });
177
+ const tools = await loadMcpToolsWithContinuity("shop", client, lock, { mode: "observe" });
178
+ ```
179
+
180
+ `observe` records every difference (`console.warn`, `onRecord`, `server.continuity.records` / `tools.continuity.records`) and passes the tools through. `review` pauses the whole server on any change until `confirm` says yes. `enforce` applies the policy: a changed or new tool is withheld from the agent, and a call to it is refused, until `confirm({ tool, continuity })` re-approves it; the rest stay usable. A refusal throws `ContinuityRefused` (with `.record`). A yes is remembered for that exact definition, so a framework that lists tools every turn does not ask twice. For CrewAI, use the Python package (`crawlcheck.crewai.with_continuity`).
124
181
 
125
182
  ## Do not trust the API: check the evidence yourself
126
183
 
@@ -0,0 +1,105 @@
1
+ export declare const CONTINUITY_EFFECTS: string[];
2
+ export declare const CONTINUITY_CHANGE_CLASSES: string[];
3
+ export declare const CONTINUITY_FAILURE_CLASSES: string[];
4
+ /** The default operator policy. Every value can be overridden; the record names the policy's sha256, so what decided is attributable. */
5
+ export declare const CONTINUITY_DEFAULT_POLICY: Readonly<{
6
+ v: 1;
7
+ id: "crawlcheck-continuity-default";
8
+ max_baseline_age_days: 90;
9
+ effects: Readonly<{
10
+ unchanged: "proceed";
11
+ tool_added: "pause_tool";
12
+ tool_removed: "proceed";
13
+ description_changed: "pause_tool";
14
+ schema_changed: "pause_tool";
15
+ annotations_changed: "pause_tool";
16
+ tool_changed: "pause_tool";
17
+ evidence_expired: "refresh";
18
+ signature_invalid: "deny";
19
+ key_unknown: "deny";
20
+ key_retired: "deny";
21
+ server_identity_mismatch: "deny";
22
+ server_unavailable: "pause";
23
+ tool_list_truncated: "pause";
24
+ response_malformed: "pause";
25
+ baseline_stale: "pause";
26
+ policy_version_mismatch: "pause";
27
+ check_timeout: "pause";
28
+ service_unavailable: "pause";
29
+ decision_refused: "deny";
30
+ }>;
31
+ }>;
32
+ /** Per-part hashes of one tool, so a change can be named (description, schema, annotations). */
33
+ export declare function lockToolParts(t: any): Promise<{
34
+ description_sha256: string;
35
+ schema_sha256: string;
36
+ annotations_sha256: string;
37
+ }>;
38
+ /** parts for a lockfile: { [tool name]: lockToolParts(tool) }. Store it as lock.parts next to lock.tools. */
39
+ export declare function lockParts(tools: any): Promise<{}>;
40
+ /** The policy in force: the default with the operator's values over it, and its sha256 (what the record names). */
41
+ export declare function continuityPolicy(p: any): Promise<{
42
+ v: number;
43
+ id: string;
44
+ max_baseline_age_days: any;
45
+ effects: Readonly<{
46
+ unchanged: "proceed";
47
+ tool_added: "pause_tool";
48
+ tool_removed: "proceed";
49
+ description_changed: "pause_tool";
50
+ schema_changed: "pause_tool";
51
+ annotations_changed: "pause_tool";
52
+ tool_changed: "pause_tool";
53
+ evidence_expired: "refresh";
54
+ signature_invalid: "deny";
55
+ key_unknown: "deny";
56
+ key_retired: "deny";
57
+ server_identity_mismatch: "deny";
58
+ server_unavailable: "pause";
59
+ tool_list_truncated: "pause";
60
+ response_malformed: "pause";
61
+ baseline_stale: "pause";
62
+ policy_version_mismatch: "pause";
63
+ check_timeout: "pause";
64
+ service_unavailable: "pause";
65
+ decision_refused: "deny";
66
+ }>;
67
+ allow_integrity_override: boolean;
68
+ } & {
69
+ sha256: string;
70
+ }>;
71
+ /**
72
+ * Compare the offered tools with the approved baseline (an mcp-lockfile, ideally with lock.parts) and apply the policy.
73
+ * opts: mode ("observe" | "review" | "enforce", default "enforce"), policy, exceptions [{tool, class, until, by, reason}],
74
+ * complete (false when tools/list returned a nextCursor you did not follow), server (the URL checked), serverIdentity
75
+ * (the URL the baseline was approved for; differs -> server_identity_mismatch), expectPolicySha256, now (ms).
76
+ */
77
+ export declare function continuityCheck(lock: any, tools: any, opts?: {}): Promise<any>;
78
+ /** A record for a check that could not compare anything (the server, the evidence or the service failed). cls: one of CONTINUITY_FAILURE_CLASSES. */
79
+ export declare function continuityFailure(cls: any, opts?: {}): Promise<any>;
80
+ /**
81
+ * Acceptance, split: the separate questions a single "verified: true" used to answer at once, for one decision receipt.
82
+ * receipt: the crawlcheck-decision. verification: verifyDocument()'s result (integrity_valid, issuer_trusted, key_policy_valid,
83
+ * unavailable). scope: checkDecision()'s result (its fields). opts: allowWarn, confirmed (true when a person approved a
84
+ * require_confirmation), lock (a continuity record or checkLock result for the same server).
85
+ * policy_result: proceed | pause | deny. accepted is policy_result === "proceed". Never act on integrity_valid alone.
86
+ */
87
+ export declare function acceptanceOf(receipt: any, verification: any, scope: any, opts?: {}): {
88
+ integrity_valid: boolean;
89
+ issuer_trusted: any;
90
+ key_policy_valid: any;
91
+ freshness_valid: boolean;
92
+ subject_matches: boolean;
93
+ operation_matches: any;
94
+ replay_safe: any;
95
+ baseline_matches: null;
96
+ assessment_complete: boolean;
97
+ decision: any;
98
+ policy_source: any;
99
+ evidence_level: any;
100
+ policy_result: string;
101
+ accepted: boolean;
102
+ why: any[];
103
+ };
104
+ /** Which failure class a refused guard() result is (for continuityFailure). */
105
+ export declare function continuityClassOf(g: any): "check_timeout" | "decision_refused" | "evidence_expired" | "key_retired" | "key_unknown" | "response_malformed" | "server_identity_mismatch" | "service_unavailable" | "signature_invalid";
@@ -0,0 +1,284 @@
1
+ // Tool-interface continuity (https://crawlcheck.io/spec/continuity), 1.11.0.
2
+ //
3
+ // The promise, and only this one: an agent does not silently proceed with a changed tool definition when the operator's
4
+ // policy requires renewed approval. The check compares the tools a server offers now with the baseline the operator
5
+ // approved (an mcp-lockfile), classifies every difference, applies the operator's policy, and returns a record of what
6
+ // was compared, which policy decided, and what the runtime was told to do.
7
+ //
8
+ // What it does NOT establish: that a tool is harmless, that its publisher is honest, that the code behind an unchanged
9
+ // description is unchanged, that a user authorised a call, or that no prompt injection exists. It is interface
10
+ // continuity at the check point (before a session, or before a call when you call it there), not code attestation.
11
+ //
12
+ // Three modes, for the pilot ladder:
13
+ // observe record what the policy would do; never change execution (outcome is always proceed, enforced false)
14
+ // review every change goes to a person (pause); integrity failures still deny
15
+ // enforce the policy's effects apply as written
16
+ // Effects, weakest to strongest: proceed, proceed_recorded (an approved exception), refresh, pause_tool, pause, deny.
17
+ // A failed exact comparison is never overridden by an interpretive assessment: nothing here reads descriptions for intent.
18
+ import { canon } from "./verify.js";
19
+ const subtle = globalThis.crypto && globalThis.crypto.subtle;
20
+ const enc = new TextEncoder();
21
+ const hex = (u8) => Array.from(u8, (b) => b.toString(16).padStart(2, "0")).join("");
22
+ async function sha256Hex(s) { return hex(new Uint8Array(await subtle.digest("SHA-256", enc.encode(s)))); }
23
+ export const CONTINUITY_EFFECTS = ["proceed", "proceed_recorded", "refresh", "pause_tool", "pause", "deny"];
24
+ export const CONTINUITY_CHANGE_CLASSES = ["tool_added", "tool_removed", "description_changed", "schema_changed", "annotations_changed", "tool_changed"];
25
+ export const CONTINUITY_FAILURE_CLASSES = ["evidence_expired", "signature_invalid", "key_unknown", "key_retired", "server_identity_mismatch", "server_unavailable", "tool_list_truncated", "response_malformed", "baseline_stale", "policy_version_mismatch", "check_timeout", "service_unavailable", "decision_refused"];
26
+ const INTEGRITY = ["signature_invalid", "key_unknown", "key_retired", "server_identity_mismatch"];
27
+ /** The default operator policy. Every value can be overridden; the record names the policy's sha256, so what decided is attributable. */
28
+ export const CONTINUITY_DEFAULT_POLICY = Object.freeze({
29
+ v: 1, id: "crawlcheck-continuity-default", max_baseline_age_days: 90,
30
+ effects: Object.freeze({
31
+ unchanged: "proceed", tool_added: "pause_tool", tool_removed: "proceed", description_changed: "pause_tool", schema_changed: "pause_tool",
32
+ annotations_changed: "pause_tool", tool_changed: "pause_tool", evidence_expired: "refresh", signature_invalid: "deny", key_unknown: "deny",
33
+ key_retired: "deny", server_identity_mismatch: "deny", server_unavailable: "pause", tool_list_truncated: "pause", response_malformed: "pause",
34
+ baseline_stale: "pause", policy_version_mismatch: "pause", check_timeout: "pause", service_unavailable: "pause", decision_refused: "deny"
35
+ })
36
+ });
37
+ const WHY = {
38
+ tool_added: "a tool the baseline does not list is offered", tool_removed: "a tool in the baseline is no longer offered",
39
+ description_changed: "the tool's description differs from the approved one", schema_changed: "the tool's input schema differs from the approved one",
40
+ annotations_changed: "the tool's annotations differ from the approved ones", tool_changed: "the tool differs from the approved one (the baseline has no per-part hashes, so the part is unknown)",
41
+ evidence_expired: "the evidence is past its validity window", signature_invalid: "a signature over the evidence does not verify",
42
+ key_unknown: "the evidence is signed by a key that is not published", key_retired: "the signing policy rejects the key for this object at its signing time",
43
+ server_identity_mismatch: "the server answering is not the one the baseline names", server_unavailable: "the server could not be reached, so nothing was compared",
44
+ tool_list_truncated: "the tool list is incomplete (a further page was not read)", response_malformed: "the tool list could not be parsed",
45
+ baseline_stale: "the approved baseline is older than the policy allows", policy_version_mismatch: "the policy in force is not the version the deployment expects",
46
+ check_timeout: "the check did not finish in time", service_unavailable: "the verification service could not be reached; the local fallback applies",
47
+ decision_refused: "CrawlCheck's signed decision for the server's host did not allow the connection"
48
+ };
49
+ /** Per-part hashes of one tool, so a change can be named (description, schema, annotations). */
50
+ export async function lockToolParts(t) {
51
+ const schema = t?.inputSchema ?? t?.input_schema ?? null;
52
+ return { description_sha256: await sha256Hex(canon(t?.description ?? null)), schema_sha256: await sha256Hex(canon(schema)), annotations_sha256: await sha256Hex(canon(t?.annotations ?? null)) };
53
+ }
54
+ /** parts for a lockfile: { [tool name]: lockToolParts(tool) }. Store it as lock.parts next to lock.tools. */
55
+ export async function lockParts(tools) { const o = {}; for (const t of (tools ?? []).slice(0, 300))
56
+ o[String(t?.name ?? "").slice(0, 120)] = await lockToolParts(t); return o; }
57
+ async function toolHash(t) { return sha256Hex(canon({ name: t?.name ?? null, description: t?.description ?? null, inputSchema: t?.inputSchema ?? t?.input_schema ?? null, annotations: t?.annotations ?? null })); }
58
+ /** The policy in force: the default with the operator's values over it, and its sha256 (what the record names). */
59
+ export async function continuityPolicy(p) {
60
+ const base = CONTINUITY_DEFAULT_POLICY, given = p && typeof p === "object" ? p : {};
61
+ const effects = Object.assign({}, base.effects);
62
+ for (const [k, v] of Object.entries(given.effects || {})) {
63
+ if (!(k in base.effects))
64
+ throw new Error("unknown continuity class: " + k);
65
+ if (CONTINUITY_EFFECTS.indexOf(v) < 0)
66
+ throw new Error("unknown effect for " + k + ": " + v);
67
+ effects[k] = v;
68
+ }
69
+ for (const k of INTEGRITY)
70
+ if (effects[k] !== "deny" && given.allow_integrity_override !== true)
71
+ throw new Error(k + " must stay deny (set allow_integrity_override to change it, and the record will say so)");
72
+ const pol = { v: 1, id: String(given.id || base.id), max_baseline_age_days: Number.isFinite(given.max_baseline_age_days) ? given.max_baseline_age_days : base.max_baseline_age_days, effects, allow_integrity_override: given.allow_integrity_override === true };
73
+ return Object.assign(pol, { sha256: await sha256Hex(canon(pol)) });
74
+ }
75
+ const rank = (e) => CONTINUITY_EFFECTS.indexOf(e);
76
+ function excFor(exceptions, cls, tool, now) {
77
+ return (exceptions || []).find((x) => x && (x.class === cls || x.class === "*") && (x.tool === tool || (!x.tool && !tool) || x.tool === "*") && (!x.until || Date.parse(x.until) > now)) || null;
78
+ }
79
+ function modeEffect(mode, cls, eff) {
80
+ if (mode === "review" && eff !== "proceed" && eff !== "proceed_recorded")
81
+ return INTEGRITY.indexOf(cls) > -1 ? "deny" : "pause";
82
+ return eff;
83
+ }
84
+ async function finish(rec, mode) {
85
+ const would = rec.events.reduce((m, e) => (rank(e.policy_effect) > rank(m) ? e.policy_effect : m), "proceed");
86
+ const eff = rec.events.reduce((m, e) => (rank(e.effect) > rank(m) ? e.effect : m), "proceed");
87
+ rec.would = would;
88
+ rec.enforced = mode !== "observe";
89
+ rec.outcome = mode === "observe" ? "proceed" : eff === "pause_tool" ? "proceed" : eff === "proceed_recorded" ? "proceed" : eff;
90
+ rec.paused_tools = mode === "observe" ? [] : rec.events.filter((e) => e.effect === "pause_tool" && e.tool).map((e) => e.tool);
91
+ rec.why = rec.events.filter((e) => e.class !== "unchanged").map((e) => (e.tool ? e.tool + ": " : "") + e.why + " -> " + e.effect + (e.exception ? " (exception by " + (e.exception.by || "?") + ")" : ""));
92
+ if (!rec.why.length)
93
+ rec.why = ["the offered tools match the approved baseline"];
94
+ rec.record_sha256 = await sha256Hex(canon(rec));
95
+ return rec;
96
+ }
97
+ function base(opts, pol, mode) {
98
+ return { kind: "crawlcheck-continuity-record", v: 1, checked_at: new Date(opts.now ?? Date.now()).toISOString(), mode, server: opts.server ?? null,
99
+ policy: { id: pol.id, sha256: pol.sha256, integrity_override: pol.allow_integrity_override },
100
+ claims: "interface continuity at this check point: the offered tool definitions were compared with the approved baseline. Not code attestation, not a judgement that any tool is harmless, not user authorisation.",
101
+ events: [] };
102
+ }
103
+ function ev(rec, pol, mode, cls, tool, exceptions, now, detail) {
104
+ const pe = pol.effects[cls];
105
+ let eff = modeEffect(mode, cls, pe);
106
+ const ex = INTEGRITY.indexOf(cls) < 0 ? excFor(exceptions, cls, tool, now) : null;
107
+ if (ex && eff !== "deny")
108
+ eff = "proceed_recorded";
109
+ const e = { class: cls, tool: tool ?? null, why: WHY[cls] || cls, policy_effect: pe, effect: eff };
110
+ if (ex)
111
+ e.exception = { by: ex.by ?? null, reason: ex.reason ?? null, until: ex.until ?? null };
112
+ if (detail)
113
+ e.detail = detail;
114
+ rec.events.push(e);
115
+ }
116
+ /**
117
+ * Compare the offered tools with the approved baseline (an mcp-lockfile, ideally with lock.parts) and apply the policy.
118
+ * opts: mode ("observe" | "review" | "enforce", default "enforce"), policy, exceptions [{tool, class, until, by, reason}],
119
+ * complete (false when tools/list returned a nextCursor you did not follow), server (the URL checked), serverIdentity
120
+ * (the URL the baseline was approved for; differs -> server_identity_mismatch), expectPolicySha256, now (ms).
121
+ */
122
+ export async function continuityCheck(lock, tools, opts = {}) {
123
+ const mode = opts.mode || "enforce";
124
+ if (["observe", "review", "enforce"].indexOf(mode) < 0)
125
+ throw new Error("mode must be observe, review or enforce");
126
+ const pol = await continuityPolicy(opts.policy), now = opts.now ?? Date.now(), X = opts.exceptions;
127
+ const rec = base(opts, pol, mode);
128
+ if (!Array.isArray(tools)) {
129
+ ev(rec, pol, mode, "response_malformed", null, X, now);
130
+ return finish(rec, mode);
131
+ }
132
+ const rows = [];
133
+ for (const t of tools.slice(0, 300))
134
+ rows.push({ name: String(t?.name ?? "").slice(0, 120), sha256: await toolHash(t), t });
135
+ const cur = {};
136
+ rows.forEach((r) => { cur[r.name] = r; });
137
+ const listHash = await sha256Hex(rows.map((r) => r.sha256).sort().join("\n"));
138
+ const was = {};
139
+ (lock?.tools ?? []).forEach((r) => { was[r.name] = r.sha256; });
140
+ const approvedAt = Date.parse(lock?.approved_at ?? "");
141
+ rec.baseline = { tools_sha256: lock?.tools_sha256 ?? null, approved_at: lock?.approved_at ?? null, age_days: isFinite(approvedAt) ? Math.floor((now - approvedAt) / 864e5) : null, server: lock?.server?.url ?? null, per_part: !!(lock && lock.parts) };
142
+ rec.observed = { tools_sha256: listHash, count: rows.length, complete: opts.complete !== false };
143
+ if (opts.expectPolicySha256 && opts.expectPolicySha256 !== pol.sha256)
144
+ ev(rec, pol, mode, "policy_version_mismatch", null, X, now, "expected " + opts.expectPolicySha256 + ", in force " + pol.sha256);
145
+ const want = opts.serverIdentity ?? lock?.server?.url ?? null;
146
+ if (want && opts.server && String(want).replace(/\/+$/, "") !== String(opts.server).replace(/\/+$/, ""))
147
+ ev(rec, pol, mode, "server_identity_mismatch", null, X, now, "baseline approved for " + want + ", checked " + opts.server);
148
+ if (rec.baseline.age_days !== null && rec.baseline.age_days > pol.max_baseline_age_days)
149
+ ev(rec, pol, mode, "baseline_stale", null, X, now, rec.baseline.age_days + " days old; the policy allows " + pol.max_baseline_age_days);
150
+ if (opts.complete === false)
151
+ ev(rec, pol, mode, "tool_list_truncated", null, X, now);
152
+ for (const n of Object.keys(cur).filter((n) => !(n in was)).sort())
153
+ ev(rec, pol, mode, "tool_added", n, X, now);
154
+ if (opts.complete !== false)
155
+ for (const n of Object.keys(was).filter((n) => !(n in cur)).sort())
156
+ ev(rec, pol, mode, "tool_removed", n, X, now);
157
+ for (const n of Object.keys(cur).filter((n) => n in was && was[n] !== cur[n].sha256).sort()) {
158
+ const P = lock && lock.parts && lock.parts[n];
159
+ if (!P) {
160
+ ev(rec, pol, mode, "tool_changed", n, X, now);
161
+ continue;
162
+ }
163
+ const q = await lockToolParts(cur[n].t);
164
+ let any = false;
165
+ if (q.description_sha256 !== P.description_sha256) {
166
+ ev(rec, pol, mode, "description_changed", n, X, now);
167
+ any = true;
168
+ }
169
+ if (q.schema_sha256 !== P.schema_sha256) {
170
+ ev(rec, pol, mode, "schema_changed", n, X, now);
171
+ any = true;
172
+ }
173
+ if (q.annotations_sha256 !== P.annotations_sha256) {
174
+ ev(rec, pol, mode, "annotations_changed", n, X, now);
175
+ any = true;
176
+ }
177
+ if (!any)
178
+ ev(rec, pol, mode, "tool_changed", n, X, now, "the name or a field outside description, schema and annotations changed");
179
+ }
180
+ if (!rec.events.length)
181
+ rec.events.push({ class: "unchanged", tool: null, why: "the offered tools match the approved baseline", policy_effect: pol.effects.unchanged, effect: modeEffect(mode, "unchanged", pol.effects.unchanged) });
182
+ return finish(rec, mode);
183
+ }
184
+ /** A record for a check that could not compare anything (the server, the evidence or the service failed). cls: one of CONTINUITY_FAILURE_CLASSES. */
185
+ export async function continuityFailure(cls, opts = {}) {
186
+ if (CONTINUITY_FAILURE_CLASSES.indexOf(cls) < 0)
187
+ throw new Error("unknown failure class: " + cls);
188
+ const mode = opts.mode || "enforce", pol = await continuityPolicy(opts.policy), now = opts.now ?? Date.now();
189
+ const rec = base(opts, pol, mode);
190
+ rec.baseline = null;
191
+ rec.observed = null;
192
+ ev(rec, pol, mode, cls, null, opts.exceptions, now, opts.detail);
193
+ return finish(rec, mode);
194
+ }
195
+ /**
196
+ * Acceptance, split: the separate questions a single "verified: true" used to answer at once, for one decision receipt.
197
+ * receipt: the crawlcheck-decision. verification: verifyDocument()'s result (integrity_valid, issuer_trusted, key_policy_valid,
198
+ * unavailable). scope: checkDecision()'s result (its fields). opts: allowWarn, confirmed (true when a person approved a
199
+ * require_confirmation), lock (a continuity record or checkLock result for the same server).
200
+ * policy_result: proceed | pause | deny. accepted is policy_result === "proceed". Never act on integrity_valid alone.
201
+ */
202
+ export function acceptanceOf(receipt, verification, scope, opts = {}) {
203
+ const r = receipt && typeof receipt === "object" ? receipt : {}, v = verification || {}, f = (scope && scope.fields) || {}, why = [];
204
+ const out = {
205
+ integrity_valid: v.integrity_valid === true, issuer_trusted: v.issuer_trusted === undefined ? null : v.issuer_trusted, key_policy_valid: v.key_policy_valid === undefined ? null : v.key_policy_valid,
206
+ freshness_valid: f.times_present === true && f.not_expired === true && f.not_future === true && f.lifetime_ok === true,
207
+ subject_matches: f.kind_ok === true && f.domain_matches === true && f.action_matches === true && f.decision_known === true && f.resolve_bound === true,
208
+ operation_matches: f.operation_matches === undefined ? null : f.operation_matches, replay_safe: f.nonce_matches === undefined ? null : f.nonce_matches,
209
+ baseline_matches: null, assessment_complete: Array.isArray(v.unavailable) && v.unavailable.length === 0 && !!r.resolve && r.resolve.status === "measured",
210
+ decision: typeof r.decision === "string" ? r.decision : null, policy_source: (r.authority && r.authority.policy_source) || null,
211
+ evidence_level: (r.evidence_assurance && r.evidence_assurance.level) || null, policy_result: "deny", accepted: false, why
212
+ };
213
+ const L = opts.lock;
214
+ if (L && L.kind === "crawlcheck-continuity-record")
215
+ out.baseline_matches = L.events.every((e) => e.class === "unchanged");
216
+ else if (L && L.verdict)
217
+ out.baseline_matches = L.verdict === "unchanged";
218
+ else if (r.assessed && r.assessed.expected_tools_sha256)
219
+ out.baseline_matches = r.assessed.matches === undefined ? null : r.assessed.matches;
220
+ if (!out.integrity_valid)
221
+ why.push("integrity: a check over the receipt's bytes failed or none ran");
222
+ if (out.issuer_trusted !== true)
223
+ why.push(out.issuer_trusted === null ? "issuer: not checked (no published key ids)" : "issuer: the signing key is not published");
224
+ if (out.key_policy_valid === false)
225
+ why.push("key policy: the key may not sign this object at its signing time");
226
+ if (!out.freshness_valid)
227
+ why.push("freshness: expired, dated in the future, missing times, or longer-lived than the action allows");
228
+ if (!out.subject_matches)
229
+ why.push("subject: the receipt is for another domain, action or answer, or its decision is unknown");
230
+ if (out.operation_matches === false)
231
+ why.push("operation: the receipt is scoped to another operation");
232
+ if (out.replay_safe === false)
233
+ why.push("replay: the receipt does not carry this request's nonce");
234
+ const hard = why.length > 0;
235
+ if (hard)
236
+ out.policy_result = "deny";
237
+ else if (out.baseline_matches === false) {
238
+ out.policy_result = "pause";
239
+ why.push("baseline: the tools differ from the approved baseline; re-approve before proceeding");
240
+ }
241
+ else if (out.decision === "allow")
242
+ out.policy_result = "proceed";
243
+ else if (out.decision === "warn") {
244
+ out.policy_result = opts.allowWarn ? "proceed" : "pause";
245
+ if (!opts.allowWarn)
246
+ why.push("decision is warn and warn is not allowed");
247
+ }
248
+ else if (out.decision === "require_confirmation") {
249
+ out.policy_result = opts.confirmed === true ? "proceed" : "pause";
250
+ if (opts.confirmed !== true)
251
+ why.push("a person must confirm");
252
+ }
253
+ else {
254
+ out.policy_result = "deny";
255
+ why.push("decision is " + out.decision);
256
+ }
257
+ out.accepted = out.policy_result === "proceed";
258
+ return out;
259
+ }
260
+ /** Which failure class a refused guard() result is (for continuityFailure). */
261
+ export function continuityClassOf(g) {
262
+ if (!g)
263
+ return "service_unavailable";
264
+ const why = (g.why || []).join("; "), A = g.acceptance;
265
+ if (!g.receipt)
266
+ return /timed? ?out|abort/i.test(why) ? "check_timeout" : "service_unavailable";
267
+ if (A) {
268
+ if (!A.integrity_valid)
269
+ return "signature_invalid";
270
+ if (A.issuer_trusted !== true)
271
+ return "key_unknown";
272
+ if (A.key_policy_valid === false)
273
+ return "key_retired";
274
+ if (!A.freshness_valid)
275
+ return "evidence_expired";
276
+ if (!A.subject_matches)
277
+ return "server_identity_mismatch";
278
+ if (A.operation_matches === false || A.replay_safe === false)
279
+ return "server_identity_mismatch";
280
+ }
281
+ else if (g.verification === null)
282
+ return "response_malformed";
283
+ return "decision_refused";
284
+ }
@@ -0,0 +1,39 @@
1
+ export declare class ContinuityRefused extends Error {
2
+ why: any;
3
+ record: any;
4
+ tool: any;
5
+ constructor(why: any, record?: null, tool?: null);
6
+ }
7
+ /** The shared gate. lock: an approved mcp-lockfile (with lock.parts so a change can be named). */
8
+ export declare function continuityGate(lock: any, opts?: {}): {
9
+ mode: any;
10
+ records: never[];
11
+ record: null;
12
+ paused: Set<any>;
13
+ keepRecord(rec: any): Promise<any>;
14
+ ask(req: any): Promise<boolean>;
15
+ /** Compare the offered tools with the lock and apply the mode. Returns the record; throws ContinuityRefused. */
16
+ check(tools: any, meta?: {}): Promise<any>;
17
+ /** Record a check that could not compare anything. Throws ContinuityRefused outside observe. */
18
+ fail(cls: any, detail: any): Promise<any>;
19
+ /** The tools the agent may see: all of them in observe mode, else without the paused ones. */
20
+ allowed(tools: any, nameOf?: (t: any) => any): any;
21
+ /** Refuse a call to a paused tool (a framework that cached an earlier list could still try it). */
22
+ callOk(name: any): boolean;
23
+ };
24
+ /**
25
+ * OpenAI Agents SDK (@openai/agents): wrap an MCP server (MCPServerStdio, MCPServerStreamableHttp, MCPServerSSE or your
26
+ * own MCPServer) so every listTools() the SDK makes is checked against lock. Everything else is the server's own.
27
+ * const guarded = withContinuity(server, lock, { mode: "observe" }); new Agent({ name, instructions, mcpServers: [guarded] })
28
+ * The records are at guarded.continuity.records.
29
+ */
30
+ export declare function withContinuity(server: any, lock: any, opts?: {}): any;
31
+ /**
32
+ * LangChain (@langchain/mcp-adapters): loadMcpTools with the continuity check.
33
+ * const tools = await loadMcpToolsWithContinuity("shop", client, lock, { mode: "observe" })
34
+ * client: a MultiServerMCPClient (its connection for serverName is used) or a connected MCP SDK Client. The list is read
35
+ * once (every page) and the LangChain tools are converted from exactly the definitions that were compared.
36
+ * opts.adapters: the @langchain/mcp-adapters module (default: imported on first use); opts.loadOptions: its
37
+ * LoadMcpToolsOptions. The gate (records) is opts.gate, or read them from opts.onRecord.
38
+ */
39
+ export declare function loadMcpToolsWithContinuity(serverName: any, client: any, lock: any, opts?: {}): Promise<any>;