@crawlcheck/sdk 1.10.0 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -1
- package/dist/continuity.d.ts +105 -0
- package/dist/continuity.js +284 -0
- package/dist/index.d.ts +158 -4
- package/dist/index.js +62 -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/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,27 @@ 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`.
|
|
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`.
|
|
124
162
|
|
|
125
163
|
## Do not trust the API: check the evidence yourself
|
|
126
164
|
|
|
@@ -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
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,131 @@
|
|
|
1
1
|
import type { paths, components } from "./gen/openapi.js";
|
|
2
2
|
import * as S from "./staple.js";
|
|
3
3
|
export type { paths, components };
|
|
4
|
+
export interface Acceptance {
|
|
5
|
+
integrity_valid: boolean;
|
|
6
|
+
issuer_trusted: boolean | null;
|
|
7
|
+
key_policy_valid: boolean | null;
|
|
8
|
+
freshness_valid: boolean;
|
|
9
|
+
subject_matches: boolean;
|
|
10
|
+
operation_matches: boolean | null;
|
|
11
|
+
replay_safe: boolean | null;
|
|
12
|
+
baseline_matches: boolean | null;
|
|
13
|
+
assessment_complete: boolean;
|
|
14
|
+
decision: string | null;
|
|
15
|
+
policy_source: string | null;
|
|
16
|
+
evidence_level: "A" | "B" | "C" | null;
|
|
17
|
+
policy_result: "proceed" | "pause" | "deny";
|
|
18
|
+
accepted: boolean;
|
|
19
|
+
why: string[];
|
|
20
|
+
}
|
|
21
|
+
export type ContinuityEffect = "proceed" | "proceed_recorded" | "refresh" | "pause_tool" | "pause" | "deny";
|
|
22
|
+
export interface ContinuityPolicy {
|
|
23
|
+
v?: 1;
|
|
24
|
+
id?: string;
|
|
25
|
+
max_baseline_age_days?: number;
|
|
26
|
+
effects?: Partial<Record<string, ContinuityEffect>>;
|
|
27
|
+
allow_integrity_override?: boolean;
|
|
28
|
+
}
|
|
29
|
+
export interface ContinuityException {
|
|
30
|
+
tool?: string;
|
|
31
|
+
class: string;
|
|
32
|
+
until?: string;
|
|
33
|
+
by?: string;
|
|
34
|
+
reason?: string;
|
|
35
|
+
}
|
|
36
|
+
export interface ContinuityEvent {
|
|
37
|
+
class: string;
|
|
38
|
+
tool: string | null;
|
|
39
|
+
why: string;
|
|
40
|
+
policy_effect: ContinuityEffect;
|
|
41
|
+
effect: ContinuityEffect;
|
|
42
|
+
exception?: {
|
|
43
|
+
by: string | null;
|
|
44
|
+
reason: string | null;
|
|
45
|
+
until: string | null;
|
|
46
|
+
};
|
|
47
|
+
detail?: string;
|
|
48
|
+
}
|
|
49
|
+
export interface ContinuityRecord {
|
|
50
|
+
kind: "crawlcheck-continuity-record";
|
|
51
|
+
v: 1;
|
|
52
|
+
checked_at: string;
|
|
53
|
+
mode: "observe" | "review" | "enforce";
|
|
54
|
+
server: string | null;
|
|
55
|
+
policy: {
|
|
56
|
+
id: string;
|
|
57
|
+
sha256: string;
|
|
58
|
+
integrity_override: boolean;
|
|
59
|
+
};
|
|
60
|
+
claims: string;
|
|
61
|
+
baseline: {
|
|
62
|
+
tools_sha256: string | null;
|
|
63
|
+
approved_at: string | null;
|
|
64
|
+
age_days: number | null;
|
|
65
|
+
server: string | null;
|
|
66
|
+
per_part: boolean;
|
|
67
|
+
} | null;
|
|
68
|
+
observed: {
|
|
69
|
+
tools_sha256: string;
|
|
70
|
+
count: number;
|
|
71
|
+
complete: boolean;
|
|
72
|
+
} | null;
|
|
73
|
+
events: ContinuityEvent[];
|
|
74
|
+
would: ContinuityEffect;
|
|
75
|
+
enforced: boolean;
|
|
76
|
+
outcome: "proceed" | "refresh" | "pause" | "deny";
|
|
77
|
+
paused_tools: string[];
|
|
78
|
+
why: string[];
|
|
79
|
+
record_sha256: string;
|
|
80
|
+
}
|
|
81
|
+
export interface ContinuityOptions {
|
|
82
|
+
mode?: "observe" | "review" | "enforce";
|
|
83
|
+
policy?: ContinuityPolicy;
|
|
84
|
+
exceptions?: ContinuityException[];
|
|
85
|
+
complete?: boolean;
|
|
86
|
+
server?: string;
|
|
87
|
+
serverIdentity?: string;
|
|
88
|
+
expectPolicySha256?: string;
|
|
89
|
+
now?: number;
|
|
90
|
+
detail?: string;
|
|
91
|
+
}
|
|
92
|
+
export declare const CONTINUITY_DEFAULT_POLICY: {
|
|
93
|
+
v: 1;
|
|
94
|
+
id: string;
|
|
95
|
+
max_baseline_age_days: number;
|
|
96
|
+
effects: Record<string, ContinuityEffect>;
|
|
97
|
+
};
|
|
98
|
+
export declare const CONTINUITY_CHANGE_CLASSES: string[];
|
|
99
|
+
export declare const CONTINUITY_FAILURE_CLASSES: string[];
|
|
100
|
+
/** Compare offered tools with the approved baseline and apply the operator's policy; returns the record to keep. */
|
|
101
|
+
export declare const continuityCheck: (lock: McpLock, tools: unknown, opts?: ContinuityOptions) => Promise<ContinuityRecord>;
|
|
102
|
+
/** The record for a check that could not compare (server, evidence or service failed). */
|
|
103
|
+
export declare const continuityFailure: (cls: string, opts?: ContinuityOptions) => Promise<ContinuityRecord>;
|
|
104
|
+
/** The policy in force (default + yours) and its sha256. */
|
|
105
|
+
export declare const continuityPolicy: (p?: ContinuityPolicy) => Promise<Required<ContinuityPolicy> & {
|
|
106
|
+
sha256: string;
|
|
107
|
+
}>;
|
|
108
|
+
/** Per-part hashes (description, schema, annotations) so a change can be named. Store lockParts(tools) as lock.parts. */
|
|
109
|
+
export declare const lockToolParts: (t: McpTool) => Promise<{
|
|
110
|
+
description_sha256: string;
|
|
111
|
+
schema_sha256: string;
|
|
112
|
+
annotations_sha256: string;
|
|
113
|
+
}>;
|
|
114
|
+
export declare const lockParts: (tools: McpTool[]) => Promise<Record<string, {
|
|
115
|
+
description_sha256: string;
|
|
116
|
+
schema_sha256: string;
|
|
117
|
+
annotations_sha256: string;
|
|
118
|
+
}>>;
|
|
119
|
+
/** The separate acceptance questions for one decision receipt. Act on policy_result, never on integrity_valid alone. */
|
|
120
|
+
/** Which continuity failure class a refused guard() result is. */
|
|
121
|
+
export declare const continuityClassOf: (g: GuardResult | null) => string;
|
|
122
|
+
export declare const acceptanceOf: (receipt: unknown, verification: unknown, scope: {
|
|
123
|
+
fields?: unknown;
|
|
124
|
+
} | null, opts?: {
|
|
125
|
+
allowWarn?: boolean;
|
|
126
|
+
confirmed?: boolean | null;
|
|
127
|
+
lock?: unknown;
|
|
128
|
+
}) => Acceptance;
|
|
4
129
|
/** One verifier check. ok: true passed, false FAILED, null could not run (why says why) - never a pass. */
|
|
5
130
|
export interface VerifyCheck {
|
|
6
131
|
id: string;
|
|
@@ -180,7 +305,12 @@ export interface McpLock {
|
|
|
180
305
|
tools: {
|
|
181
306
|
name: string;
|
|
182
307
|
sha256: string;
|
|
183
|
-
}[];
|
|
308
|
+
}[]; /** 1.11.0: per-part hashes (lockParts) so continuityCheck can name what changed. */
|
|
309
|
+
parts?: Record<string, {
|
|
310
|
+
description_sha256: string;
|
|
311
|
+
schema_sha256: string;
|
|
312
|
+
annotations_sha256: string;
|
|
313
|
+
}>;
|
|
184
314
|
descriptions?: Record<string, string>;
|
|
185
315
|
server?: {
|
|
186
316
|
url?: string | null;
|
|
@@ -303,16 +433,31 @@ export interface DecisionWant {
|
|
|
303
433
|
* time window, per-action lifetime, known decision, resolve binding); the signature and issuer are checked by
|
|
304
434
|
* verifyDocument. guard() runs both. https://crawlcheck.io/spec/guard
|
|
305
435
|
*/
|
|
436
|
+
export interface DecisionScopeFields {
|
|
437
|
+
kind_ok: boolean;
|
|
438
|
+
domain_matches: boolean;
|
|
439
|
+
action_matches: boolean;
|
|
440
|
+
operation_matches?: boolean;
|
|
441
|
+
nonce_matches?: boolean;
|
|
442
|
+
times_present: boolean;
|
|
443
|
+
not_expired: boolean;
|
|
444
|
+
not_future: boolean;
|
|
445
|
+
lifetime_ok: boolean;
|
|
446
|
+
decision_known: boolean;
|
|
447
|
+
resolve_bound: boolean;
|
|
448
|
+
}
|
|
306
449
|
export declare function checkDecision(receipt: unknown, want: DecisionWant): {
|
|
307
450
|
ok: boolean;
|
|
308
451
|
why: string[];
|
|
452
|
+
fields: DecisionScopeFields;
|
|
309
453
|
};
|
|
310
454
|
export interface GuardResult {
|
|
311
455
|
proceed: boolean;
|
|
312
456
|
receipt: DecisionReceipt | null;
|
|
313
457
|
verification: (DocumentVerification & Trust) | null;
|
|
314
458
|
header: Record<string, string> | null;
|
|
315
|
-
why: string[];
|
|
459
|
+
why: string[]; /** 1.11.0: the separate acceptance questions (integrity, issuer, key policy, freshness, subject, operation, replay, baseline, completeness) and policy_result. Null when no receipt came back. */
|
|
460
|
+
acceptance: Acceptance | null;
|
|
316
461
|
split_view?: LogFork[]; /** Set when the domain was refused from the held revocation list, with no network call. */
|
|
317
462
|
revoked?: RevokedEntry;
|
|
318
463
|
}
|
|
@@ -528,6 +673,10 @@ export interface McpPreflightOptions extends PreflightOptions {
|
|
|
528
673
|
local?: "allow" | "refuse";
|
|
529
674
|
/** The server URL when the transport does not expose it. */
|
|
530
675
|
serverUrl?: string;
|
|
676
|
+
/** 1.11.0: tool-interface continuity with lock. mode observe (record only), review (changes go to confirm), enforce (the policy applies). Every check is kept in state.records and passed to onRecord. https://crawlcheck.io/spec/continuity */
|
|
677
|
+
continuity?: ContinuityOptions & {
|
|
678
|
+
onRecord?: (record: ContinuityRecord) => unknown;
|
|
679
|
+
};
|
|
531
680
|
onlineKeys?: boolean;
|
|
532
681
|
}
|
|
533
682
|
export interface McpPreflightState {
|
|
@@ -536,8 +685,11 @@ export interface McpPreflightState {
|
|
|
536
685
|
receipt: DecisionReceipt | null;
|
|
537
686
|
verification: (DocumentVerification & Trust) | null;
|
|
538
687
|
decidedAt: number;
|
|
539
|
-
lockCheck: McpLockCheck | null;
|
|
688
|
+
lockCheck: McpLockCheck | ContinuityRecord | null;
|
|
540
689
|
approved: Set<string> | null;
|
|
690
|
+
continuity: ContinuityRecord | null;
|
|
691
|
+
records: ContinuityRecord[];
|
|
692
|
+
pausedTools: Set<string> | null;
|
|
541
693
|
}
|
|
542
694
|
export interface McpPreflightHooks {
|
|
543
695
|
state: McpPreflightState;
|
|
@@ -547,7 +699,9 @@ export interface McpPreflightHooks {
|
|
|
547
699
|
receipt: DecisionReceipt | null;
|
|
548
700
|
verification?: DocumentVerification & Trust;
|
|
549
701
|
}>;
|
|
550
|
-
afterConnect(tools: McpTool[]
|
|
702
|
+
afterConnect(tools: McpTool[] | null, meta?: {
|
|
703
|
+
nextCursor?: string | null;
|
|
704
|
+
} | null): Promise<McpLockCheck | ContinuityRecord | null>;
|
|
551
705
|
beforeToolCall(name: string, annotations?: unknown): Promise<{
|
|
552
706
|
receipt: DecisionReceipt | null;
|
|
553
707
|
}>;
|