@interlok/cli 0.0.0-stage → 1.3.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/dist/proxy.js ADDED
@@ -0,0 +1,361 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createServer } from "node:http";
3
+ import { createInterface } from "node:readline";
4
+ export const isToolCall = (m) => m?.method === "tools/call" && m.id !== undefined;
5
+ /** A tools/call answer the model can read: MCP reports a refused call as a result with isError. */
6
+ export function refusal(id, text) {
7
+ return { jsonrpc: "2.0", id, result: { isError: true, content: [{ type: "text", text }] } };
8
+ }
9
+ function client(cfg) {
10
+ const f = cfg.fetch ?? fetch;
11
+ return async (path, init = {}) => {
12
+ const r = await f(`${cfg.api}${path}`, {
13
+ ...init,
14
+ headers: {
15
+ "content-type": "application/json",
16
+ authorization: `Bearer ${cfg.key}`,
17
+ "x-interlok-agent": cfg.agent,
18
+ },
19
+ signal: AbortSignal.timeout(30_000),
20
+ });
21
+ const text = await r.text();
22
+ let body = text;
23
+ try {
24
+ body = JSON.parse(text);
25
+ }
26
+ catch {
27
+ /* keep text */
28
+ }
29
+ if (!r.ok)
30
+ throw Object.assign(new Error(`Interlok ${r.status}: ${body?.error ?? text}`), {
31
+ status: r.status,
32
+ });
33
+ return body;
34
+ };
35
+ }
36
+ /** MCP `_meta` key an agent may use to say what it is trying to do (intent, alternatives, confidence). */
37
+ export const DECISION_META_KEY = "interlok/decision";
38
+ /**
39
+ * Take the agent's decision record off the call. It is for Interlok, so it never reaches the MCP
40
+ * server: the key is deleted from the message in place, before the message can be forwarded.
41
+ */
42
+ export function takeDecisionRecord(msg) {
43
+ const meta = msg.params?._meta;
44
+ if (!meta || typeof meta !== "object" || !(DECISION_META_KEY in meta))
45
+ return undefined;
46
+ const record = meta[DECISION_META_KEY];
47
+ delete meta[DECISION_META_KEY];
48
+ if (!Object.keys(meta).length)
49
+ delete msg.params._meta;
50
+ return record && typeof record === "object" && !Array.isArray(record) ? record : undefined;
51
+ }
52
+ /** One line for the model: why, and what to do next. */
53
+ export function guidanceText(g) {
54
+ if (!g || typeof g !== "object")
55
+ return "";
56
+ const next = {
57
+ with_changes: "Change the call to address this before trying again.",
58
+ later: "Try again later.",
59
+ do_not_retry: "Do not retry this call.",
60
+ after_determination: "Wait for a person to decide.",
61
+ ask_a_person: "Ask a person before trying again.",
62
+ };
63
+ const parts = [g.alternative ? `Suggested instead: ${g.alternative}.` : "", next[g.retry] ?? ""];
64
+ return parts.filter(Boolean).join(" ");
65
+ }
66
+ /** Decide one tools/call. Shadow never blocks; Gate forwards only on allow or a human release. */
67
+ export function decider(cfg) {
68
+ const call = client(cfg);
69
+ const log = cfg.log ?? ((l) => process.stderr.write(`[interlok] ${l}\n`));
70
+ const sleep = cfg.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
71
+ return async (msg) => {
72
+ const tool = String(msg.params?.name ?? "");
73
+ const args = (msg.params?.arguments ?? {});
74
+ const decision_record = takeDecisionRecord(msg);
75
+ if (cfg.shadowRun) {
76
+ try {
77
+ await call(`/api/v1/shadow/runs/${encodeURIComponent(cfg.shadowRun)}/events`, {
78
+ method: "POST",
79
+ body: JSON.stringify({ tool, args, connection_name: cfg.connection }),
80
+ });
81
+ }
82
+ catch (e) {
83
+ log(`shadow: could not record ${tool}: ${e.message}`);
84
+ }
85
+ return { forward: true };
86
+ }
87
+ let v;
88
+ try {
89
+ v = await call("/api/v1/gate", {
90
+ method: "POST",
91
+ body: JSON.stringify({
92
+ agent: { name: cfg.agent },
93
+ connection: { name: cfg.connection, kind: "mcp" },
94
+ tool,
95
+ args,
96
+ chokepoint: "mcp_tool",
97
+ environment: cfg.environment,
98
+ request_id: msg.id === undefined || msg.id === null ? undefined : `${cfg.connection}:${msg.id}`,
99
+ wait_ms: Math.min(cfg.waitSeconds, 25) * 1000,
100
+ ...(decision_record ? { decision_record } : {}),
101
+ }),
102
+ });
103
+ }
104
+ catch (e) {
105
+ log(`gate unreachable for ${tool}: ${e.message}`);
106
+ return cfg.failOpen
107
+ ? { forward: true }
108
+ : {
109
+ forward: false,
110
+ message: `Interlok could not be reached, so ${tool} was not run (fail closed).`,
111
+ };
112
+ }
113
+ if (v.effect === "allow" || v.status === "released")
114
+ return { forward: true };
115
+ if (v.effect === "deny" || !v.hold) {
116
+ log(`denied ${tool}: ${v.reason}`);
117
+ return {
118
+ forward: false,
119
+ message: `Interlok denied ${tool}: ${v.reason || "policy"}${v.rule_id ? ` (rule ${v.rule_id})` : ""}. ${guidanceText(v.guidance) || "Do not retry this call."}`,
120
+ };
121
+ }
122
+ // Held: wait for a person, up to waitSeconds in total (the first 25 s were spent in the gate call).
123
+ log(`held ${tool} as ${v.hold.tag_number}: waiting for a person`);
124
+ const deadline = Date.now() + Math.max(0, cfg.waitSeconds - 25) * 1000;
125
+ while (Date.now() < deadline) {
126
+ await sleep(Math.min(5000, Math.max(0, deadline - Date.now())));
127
+ try {
128
+ const h = await call(`/api/v1/holds/${encodeURIComponent(v.hold.id)}`);
129
+ if (h.state === "released")
130
+ return { forward: true };
131
+ if (h.state && h.state !== "open")
132
+ return {
133
+ forward: false,
134
+ message: `${tool} was ${h.state} (${v.hold.tag_number}). Do not retry without asking a person.`,
135
+ };
136
+ }
137
+ catch (e) {
138
+ log(`hold ${v.hold.tag_number}: ${e.message}`);
139
+ }
140
+ }
141
+ return {
142
+ forward: false,
143
+ message: `${tool} is held for a person to decide (${v.hold.tag_number}, ${v.reason}). It was not run. Tell the user and retry after it is released.`,
144
+ };
145
+ };
146
+ }
147
+ /** Register the server's tools once its tools/list answer comes back: pins descriptions for drift. */
148
+ export async function registerTools(cfg, tools, transport, url) {
149
+ if (cfg.shadowRun || !Array.isArray(tools) || !tools.length)
150
+ return;
151
+ try {
152
+ await client(cfg)("/api/v1/connections", {
153
+ method: "POST",
154
+ body: JSON.stringify({
155
+ name: cfg.connection,
156
+ kind: "mcp",
157
+ transport,
158
+ url,
159
+ tools: tools.slice(0, 500).map((t) => ({
160
+ name: String(t.name),
161
+ description: t.description,
162
+ input_schema: t.inputSchema,
163
+ annotations: t.annotations,
164
+ })),
165
+ }),
166
+ });
167
+ }
168
+ catch (e) {
169
+ (cfg.log ?? ((l) => process.stderr.write(`[interlok] ${l}\n`)))(`could not register tools: ${e.message}`);
170
+ }
171
+ }
172
+ /**
173
+ * The message pump shared by both transports. `toServer` / `toClient` write one message each way.
174
+ * Tool calls are decided concurrently so a held call never blocks pings, cancellations or other calls.
175
+ */
176
+ export function interceptor(cfg, toServer, toClient, transport) {
177
+ const decide = decider(cfg);
178
+ const listIds = new Set();
179
+ return {
180
+ fromClient(m) {
181
+ if (m?.method === "tools/list" && m.id !== undefined)
182
+ listIds.add(String(m.id));
183
+ if (!isToolCall(m))
184
+ return toServer(m);
185
+ void decide(m).then((d) => (d.forward ? toServer(m) : toClient(refusal(m.id, d.message ?? "refused"))), () => toClient(refusal(m.id, "Interlok could not decide this call; it was not run.")));
186
+ },
187
+ fromServer(m) {
188
+ if (m?.id !== undefined && listIds.delete(String(m.id)) && m.result?.tools)
189
+ void registerTools(cfg, m.result.tools, transport);
190
+ toClient(m);
191
+ },
192
+ };
193
+ }
194
+ /* ------------------------------ stdio ------------------------------ */
195
+ export async function proxyStdio(cfg, command, args) {
196
+ // The server gets the caller's environment minus Interlok's own credentials.
197
+ const env = { ...process.env };
198
+ for (const k of ["INTERLOK_API_KEY", "INTERLOK_SHADOW_RUN"])
199
+ delete env[k];
200
+ const child = spawn(command, args, { stdio: ["pipe", "pipe", "inherit"], env });
201
+ const send = (stream) => (m) => stream.write(JSON.stringify(m) + "\n");
202
+ const io = interceptor(cfg, send(child.stdin), send(process.stdout), "stdio");
203
+ const pump = (input, onMsg, raw) => createInterface({ input, crlfDelay: Infinity }).on("line", (line) => {
204
+ if (!line.trim())
205
+ return;
206
+ let parsed;
207
+ try {
208
+ parsed = JSON.parse(line);
209
+ }
210
+ catch {
211
+ return raw(line); // not ours to judge; pass it on
212
+ }
213
+ for (const m of Array.isArray(parsed) ? parsed : [parsed])
214
+ onMsg(m);
215
+ });
216
+ pump(process.stdin, io.fromClient, (l) => child.stdin.write(l + "\n"));
217
+ pump(child.stdout, io.fromServer, (l) => process.stdout.write(l + "\n"));
218
+ process.stdin.on("end", () => child.stdin.end());
219
+ return new Promise((resolve, reject) => {
220
+ child.on("error", reject);
221
+ child.on("exit", (c) => resolve(c ?? 1));
222
+ });
223
+ }
224
+ /* --------------------------- streamable HTTP --------------------------- */
225
+ const HOP = new Set([
226
+ "host",
227
+ "connection",
228
+ "content-length",
229
+ "transfer-encoding",
230
+ "keep-alive",
231
+ "upgrade",
232
+ ]);
233
+ function readBody(req, limit = 8 * 1024 * 1024) {
234
+ return new Promise((resolve, reject) => {
235
+ let n = 0;
236
+ const chunks = [];
237
+ req.on("data", (c) => {
238
+ n += c.length;
239
+ if (n > limit) {
240
+ reject(new Error("request too large"));
241
+ req.destroy();
242
+ }
243
+ else
244
+ chunks.push(c);
245
+ });
246
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
247
+ req.on("error", reject);
248
+ });
249
+ }
250
+ /** Streamable HTTP: the client talks to http://127.0.0.1:<port>/mcp; the proxy talks to --upstream. */
251
+ export function proxyHttp(cfg, upstream, port, host = "127.0.0.1") {
252
+ const f = cfg.fetch ?? fetch;
253
+ const decide = decider(cfg);
254
+ async function forward(req, res, body) {
255
+ const headers = {};
256
+ for (const [k, v] of Object.entries(req.headers))
257
+ if (!HOP.has(k) && typeof v === "string" && !k.startsWith("x-interlok"))
258
+ headers[k] = v;
259
+ const up = await f(upstream, { method: req.method, headers, body: body || undefined });
260
+ const out = {};
261
+ up.headers.forEach((v, k) => {
262
+ if (!HOP.has(k) && k !== "content-encoding")
263
+ out[k] = v;
264
+ });
265
+ const isList = body?.includes('"tools/list"');
266
+ if (isList && up.headers.get("content-type")?.includes("application/json")) {
267
+ const text = await up.text();
268
+ try {
269
+ const j = JSON.parse(text);
270
+ if (j?.result?.tools)
271
+ void registerTools(cfg, j.result.tools, "streamable-http", upstream);
272
+ }
273
+ catch {
274
+ /* pass through as-is */
275
+ }
276
+ res.writeHead(up.status, out).end(text);
277
+ return;
278
+ }
279
+ res.writeHead(up.status, out);
280
+ if (!up.body)
281
+ return res.end();
282
+ const reader = up.body.getReader();
283
+ for (;;) {
284
+ const { done, value } = await reader.read();
285
+ if (done)
286
+ break;
287
+ res.write(Buffer.from(value));
288
+ }
289
+ res.end();
290
+ }
291
+ const server = createServer(async (req, res) => {
292
+ try {
293
+ if (req.method !== "POST")
294
+ return await forward(req, res);
295
+ const body = await readBody(req);
296
+ let msg;
297
+ try {
298
+ msg = JSON.parse(body);
299
+ }
300
+ catch {
301
+ return await forward(req, res, body);
302
+ }
303
+ const calls = (Array.isArray(msg) ? msg : [msg]).filter(isToolCall);
304
+ if (calls.length > 1) {
305
+ res.writeHead(400, { "content-type": "application/json" });
306
+ return res.end(JSON.stringify({ error: "batched tool calls are not supported through Interlok" }));
307
+ }
308
+ if (calls.length === 1) {
309
+ const d = await decide(calls[0]);
310
+ if (!d.forward) {
311
+ res.writeHead(200, { "content-type": "application/json" });
312
+ return res.end(JSON.stringify(refusal(calls[0].id, d.message ?? "refused")));
313
+ }
314
+ }
315
+ await forward(req, res, body);
316
+ }
317
+ catch (e) {
318
+ if (!res.headersSent)
319
+ res.writeHead(502, { "content-type": "application/json" });
320
+ res.end(JSON.stringify({ error: `proxy: ${e.message}` }));
321
+ }
322
+ });
323
+ return new Promise((resolve) => server.listen(port, host, () => resolve(server)));
324
+ }
325
+ /* ------------------------------ command ------------------------------ */
326
+ export async function proxyCommand(args, value, has) {
327
+ const api = (process.env.INTERLOK_URL ??
328
+ process.env.INTERLOK_API_URL ??
329
+ value("--url") ??
330
+ "https://interlok.io").replace(/\/$/, "");
331
+ const key = process.env.INTERLOK_API_KEY ?? value("--api-key") ?? "";
332
+ if (!key)
333
+ throw new Error("INTERLOK_API_KEY is required (Console → Settings → API keys)");
334
+ const sep = args.indexOf("--");
335
+ const child = sep >= 0 ? args.slice(sep + 1) : [];
336
+ const upstream = value("--upstream");
337
+ if (!child.length && !upstream)
338
+ throw new Error("usage: interlok proxy --name <server> -- <command…> | --upstream <url> [--port 8787] [--host 127.0.0.1]");
339
+ const env = value("--env");
340
+ const cfg = {
341
+ api,
342
+ key,
343
+ agent: value("--agent") ?? process.env.INTERLOK_AGENT ?? "mcp-agent",
344
+ connection: value("--name") ?? (upstream ? new URL(upstream).host : child[child.length - 1]),
345
+ shadowRun: value("--shadow") ?? process.env.INTERLOK_SHADOW_RUN,
346
+ environment: env === "staging" || env === "dev" || env === "production" ? env : undefined,
347
+ waitSeconds: Number(value("--wait") ?? 300),
348
+ failOpen: has("--fail-open"),
349
+ };
350
+ process.stderr.write(`[interlok] ${cfg.shadowRun ? `shadow run ${cfg.shadowRun}: recording, never blocking` : "gate: risky calls wait for a person"} · ${cfg.connection}\n`);
351
+ if (upstream) {
352
+ const port = Number(value("--port") ?? 8787);
353
+ // Loopback by default. Cloud agent builders (Copilot Studio, Agentforce, AgentCore) need the
354
+ // proxy behind your HTTPS ingress: bind to 0.0.0.0 inside a container, never straight to the internet.
355
+ const host = value("--host") ?? "127.0.0.1";
356
+ await proxyHttp(cfg, upstream, port, host);
357
+ process.stderr.write(`[interlok] point your MCP client at http://${host}:${port}/mcp\n`);
358
+ return new Promise(() => { });
359
+ }
360
+ process.exitCode = await proxyStdio(cfg, child[0], child.slice(1));
361
+ }
package/dist/scan.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ export type Finding = {
2
+ severity: "critical" | "warning";
3
+ code: string;
4
+ file: string;
5
+ message: string;
6
+ };
7
+ export declare function scan(path: string): Promise<Finding[]>;
package/dist/scan.js ADDED
@@ -0,0 +1,41 @@
1
+ import { readFile, readdir } from "node:fs/promises";
2
+ import { statSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ const SKIP = new Set(["node_modules", ".git", "dist", ".next", ".output"]);
5
+ async function files(path) {
6
+ if (!statSync(path).isDirectory())
7
+ return [path];
8
+ const out = [];
9
+ for (const name of await readdir(path)) {
10
+ if (SKIP.has(name))
11
+ continue;
12
+ const p = join(path, name);
13
+ if (statSync(p).isDirectory())
14
+ out.push(...(await files(p)));
15
+ else if (/\.(json|ya?ml|toml|ts|js|py)$/i.test(name))
16
+ out.push(p);
17
+ }
18
+ return out;
19
+ }
20
+ export async function scan(path) {
21
+ const findings = [];
22
+ for (const file of await files(path)) {
23
+ const text = await readFile(file, "utf8").catch(() => "");
24
+ if (/\b(delete|truncate|deploy|create_release|apply|write)\b/i.test(text) &&
25
+ !/interlok|gate|policy/i.test(text))
26
+ findings.push({
27
+ severity: "critical",
28
+ code: "ILK001",
29
+ file,
30
+ message: "Write-capable tool path has no visible Interlok boundary",
31
+ });
32
+ if (/(api[_-]?key|token|secret)\s*[:=]\s*["'][^"']{8,}/i.test(text))
33
+ findings.push({
34
+ severity: "warning",
35
+ code: "ILK002",
36
+ file,
37
+ message: "Credential-like value is embedded in configuration",
38
+ });
39
+ }
40
+ return findings;
41
+ }
@@ -0,0 +1,34 @@
1
+ type Entry = {
2
+ seq: number;
3
+ prev_hash: string;
4
+ payload: unknown;
5
+ payload_hash: string;
6
+ entry_hash: string;
7
+ signature: string;
8
+ signer_kid: string;
9
+ };
10
+ type Bundle = {
11
+ entries: Entry[];
12
+ keys?: Record<string, JsonWebKey>;
13
+ public_keys?: Record<string, JsonWebKey>;
14
+ };
15
+ type Result = {
16
+ ok: true;
17
+ checked: number;
18
+ detail?: string;
19
+ } | {
20
+ ok: false;
21
+ seq: number;
22
+ reason: string;
23
+ };
24
+ export declare function verifyBundle(bundle: Bundle): Promise<Result>;
25
+ /**
26
+ * Verifies a downloaded bundle offline. Beyond the signatures:
27
+ * - interlok.shadow.v1: the event list matches the signed manifest hash. For report 1.1 the
28
+ * coverage and the run diff must also match the hashes the receipt signed, so neither a reach
29
+ * tier nor a coverage number can be edited after the fact.
30
+ * - interlok.public.v1: the published snapshot matches the hash signed when it was published.
31
+ */
32
+ export declare function verifyParsed(bundle: Bundle & Record<string, any>): Promise<Result>;
33
+ export declare function verifyFile(path: string): Promise<Result>;
34
+ export {};
package/dist/verify.js ADDED
@@ -0,0 +1,123 @@
1
+ import { createHash, webcrypto } from "node:crypto";
2
+ import { readFile } from "node:fs/promises";
3
+ import { canonical } from "./canonical.js";
4
+ import { payloadRootV2 } from "./proof-verify.js";
5
+ const sha = (s) => createHash("sha256").update(s).digest("hex");
6
+ const b64url = (s) => Buffer.from(s.replace(/-/g, "+").replace(/_/g, "/"), "base64");
7
+ export async function verifyBundle(bundle) {
8
+ const keys = bundle.keys ?? bundle.public_keys ?? {};
9
+ let prev = bundle.entries[0]?.prev_hash ?? "0".repeat(64);
10
+ for (const e of bundle.entries) {
11
+ if (e.prev_hash !== prev)
12
+ return { ok: false, seq: e.seq, reason: "prev_hash mismatch" };
13
+ if (e.payload?.record_version === 2) {
14
+ const salts = e.field_salts;
15
+ if (!salts)
16
+ return { ok: false, seq: e.seq, reason: "v2 record exported without its field salts" };
17
+ if ((await payloadRootV2(e.payload, salts).catch(() => "")) !== e.payload_hash)
18
+ return { ok: false, seq: e.seq, reason: "payload tampered" };
19
+ }
20
+ else if (sha(canonical(e.payload)) !== e.payload_hash)
21
+ return { ok: false, seq: e.seq, reason: "payload tampered" };
22
+ if (sha(`${e.seq}|${e.prev_hash}|${e.payload_hash}`) !== e.entry_hash)
23
+ return { ok: false, seq: e.seq, reason: "entry_hash mismatch" };
24
+ const jwk = keys[e.signer_kid];
25
+ if (!jwk)
26
+ return { ok: false, seq: e.seq, reason: `unknown signer ${e.signer_kid}` };
27
+ const key = await webcrypto.subtle.importKey("jwk", jwk, { name: "ECDSA", namedCurve: "P-256" }, false, ["verify"]);
28
+ const ok = await webcrypto.subtle.verify({ name: "ECDSA", hash: "SHA-256" }, key, b64url(e.signature), Buffer.from(e.entry_hash));
29
+ if (!ok)
30
+ return { ok: false, seq: e.seq, reason: "bad signature" };
31
+ prev = e.entry_hash;
32
+ }
33
+ return { ok: true, checked: bundle.entries.length };
34
+ }
35
+ /**
36
+ * Verifies a downloaded bundle offline. Beyond the signatures:
37
+ * - interlok.shadow.v1: the event list matches the signed manifest hash. For report 1.1 the
38
+ * coverage and the run diff must also match the hashes the receipt signed, so neither a reach
39
+ * tier nor a coverage number can be edited after the fact.
40
+ * - interlok.public.v1: the published snapshot matches the hash signed when it was published.
41
+ */
42
+ export async function verifyParsed(bundle) {
43
+ if (bundle.format === "interlok.assessor-bundle/v1")
44
+ return verifyAssessor(bundle);
45
+ if (!Array.isArray(bundle.entries))
46
+ return { ok: false, seq: 0, reason: "not an Interlok evidence file (no entries)" };
47
+ const result = await verifyBundle(bundle);
48
+ if (!result.ok)
49
+ return result;
50
+ const first = bundle.entries[0];
51
+ const data = (first?.payload?.data ?? {});
52
+ const seq = first?.seq ?? 0;
53
+ if (bundle.format === "interlok.shadow.v1") {
54
+ if (data.event_manifest_hash !== sha(canonical(bundle.events ?? [])))
55
+ return { ok: false, seq, reason: "Shadow event manifest tampered" };
56
+ if (data.report_version === "1.1") {
57
+ if (data.coverage_hash !== undefined &&
58
+ data.coverage_hash !== sha(canonical(bundle.coverage ?? null)))
59
+ return { ok: false, seq, reason: "coverage does not match the signed receipt" };
60
+ if (data.diff_hash !== undefined && data.diff_hash !== sha(canonical(bundle.diff ?? null)))
61
+ return { ok: false, seq, reason: "run diff does not match the signed receipt" };
62
+ return { ...result, detail: "events, coverage, and run diff match the signed receipt" };
63
+ }
64
+ return { ...result, detail: "events match the signed receipt" };
65
+ }
66
+ if (bundle.format === "interlok.public.v1") {
67
+ if (data.snapshot_hash !== sha(canonical(bundle.snapshot ?? null)))
68
+ return { ok: false, seq, reason: "published snapshot does not match the signed receipt" };
69
+ return { ...result, detail: "the published report matches what was signed" };
70
+ }
71
+ return result;
72
+ }
73
+ /**
74
+ * interlok.assessor-bundle/v1: a signed attestation plus every ledger record it covers. Checks the
75
+ * attestation document against its signed hash and signature, every record, and that the records
76
+ * are exactly the range the attestation names (first, last and head hash).
77
+ */
78
+ async function verifyAssessor(b) {
79
+ const att = b.attestation ?? {};
80
+ const ledger = b.ledger ?? {};
81
+ const entries = Array.isArray(ledger.entries) ? ledger.entries : [];
82
+ const keys = ledger.keys ?? {};
83
+ if (sha(canonical(att.document ?? null)) !== att.document_hash)
84
+ return { ok: false, seq: 0, reason: "attestation document does not match its signed hash" };
85
+ const jwk = keys[att.signer_kid];
86
+ if (!jwk)
87
+ return { ok: false, seq: 0, reason: `unknown attestation signer ${att.signer_kid}` };
88
+ const key = await webcrypto.subtle.importKey("jwk", jwk, { name: "ECDSA", namedCurve: "P-256" }, false, ["verify"]);
89
+ const signed = await webcrypto.subtle.verify({ name: "ECDSA", hash: "SHA-256" }, key, b64url(String(att.signature ?? "")), Buffer.from(String(att.document_hash ?? "")));
90
+ if (!signed)
91
+ return { ok: false, seq: 0, reason: "bad signature on the attestation" };
92
+ const range = att.document?.ledger_range ?? null;
93
+ if (!range) {
94
+ if (entries.length)
95
+ return { ok: false, seq: 0, reason: "attestation covers no records but the file has some" };
96
+ return {
97
+ ok: true,
98
+ checked: 0,
99
+ detail: "attestation signature verified; no records in the period",
100
+ };
101
+ }
102
+ const chain = await verifyBundle({ entries, keys });
103
+ if (!chain.ok)
104
+ return chain;
105
+ const first = entries[0];
106
+ const last = entries[entries.length - 1];
107
+ if (!first || Number(first.seq) !== range.from_seq || Number(last.seq) !== range.to_seq)
108
+ return {
109
+ ok: false,
110
+ seq: Number(first?.seq ?? 0),
111
+ reason: "records are not the attested range",
112
+ };
113
+ if (last.entry_hash !== range.head_hash)
114
+ return { ok: false, seq: Number(last.seq), reason: "last record is not the attested head" };
115
+ return {
116
+ ok: true,
117
+ checked: chain.checked,
118
+ detail: "attestation signature and range verified against the records",
119
+ };
120
+ }
121
+ export async function verifyFile(path) {
122
+ return verifyParsed(JSON.parse(await readFile(path, "utf8")));
123
+ }
package/package.json CHANGED
@@ -1,6 +1,33 @@
1
1
  {
2
2
  "name": "@interlok/cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.3.0",
4
+ "description": "Scan agent configurations, put MCP servers behind the Interlok gate, run Shadow observations, and verify receipts offline.",
5
+ "license": "Apache-2.0",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/anytechlabs/interlok-vercel.git",
9
+ "directory": "cli"
10
+ },
11
+ "homepage": "https://interlok.io/integrations/agents",
12
+ "type": "module",
13
+ "bin": {
14
+ "interlok": "dist/index.js"
15
+ },
16
+ "files": [
17
+ "dist",
18
+ "README.md",
19
+ "LICENSE"
20
+ ],
21
+ "scripts": {
22
+ "build": "tsc -p tsconfig.json",
23
+ "test": "vitest run"
24
+ },
25
+ "devDependencies": {
26
+ "@types/node": "^22.16.5",
27
+ "typescript": "^5.8.3",
28
+ "vitest": "^5.0.1"
29
+ },
30
+ "dependencies": {
31
+ "fflate": "^0.8.3"
32
+ }
33
+ }