@interlok/cli 0.0.0-stage → 1.3.1
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/LICENSE +5 -0
- package/README.md +158 -2
- package/dist/approvals-mcp.d.ts +83 -0
- package/dist/approvals-mcp.js +185 -0
- package/dist/canonical.d.ts +1 -0
- package/dist/canonical.js +20 -0
- package/dist/classify.d.ts +107 -0
- package/dist/classify.js +524 -0
- package/dist/gate.d.ts +19 -0
- package/dist/gate.js +338 -0
- package/dist/hook.d.ts +108 -0
- package/dist/hook.js +578 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +241 -0
- package/dist/inventory.d.ts +53 -0
- package/dist/inventory.js +210 -0
- package/dist/packs.d.ts +105 -0
- package/dist/packs.js +186 -0
- package/dist/proof-verify.d.ts +108 -0
- package/dist/proof-verify.js +513 -0
- package/dist/proxy.d.ts +69 -0
- package/dist/proxy.js +361 -0
- package/dist/scan.d.ts +7 -0
- package/dist/scan.js +41 -0
- package/dist/verify.d.ts +34 -0
- package/dist/verify.js +123 -0
- package/package.json +31 -4
package/dist/index.js
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { appendFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import { scan } from "./scan.js";
|
|
5
|
+
import { verifyFile } from "./verify.js";
|
|
6
|
+
import { verifyProofBundle } from "./proof-verify.js";
|
|
7
|
+
import { readFile } from "node:fs/promises";
|
|
8
|
+
import { coverageOf, enumerate, findInventory, scanVerdict, unlistedServers } from "./inventory.js";
|
|
9
|
+
import { coverageLine } from "./classify.js";
|
|
10
|
+
import { gateCommand } from "./gate.js";
|
|
11
|
+
import { proxyCommand } from "./proxy.js";
|
|
12
|
+
import { approvalsMcpCommand } from "./approvals-mcp.js";
|
|
13
|
+
import { hookCommand } from "./hook.js";
|
|
14
|
+
const [command, ...args] = process.argv.slice(2);
|
|
15
|
+
/** `--name value` or `--name=value`. */
|
|
16
|
+
const value = (name) => {
|
|
17
|
+
const eq = args.find((a) => a.startsWith(`${name}=`));
|
|
18
|
+
if (eq)
|
|
19
|
+
return eq.slice(name.length + 1);
|
|
20
|
+
const i = args.indexOf(name);
|
|
21
|
+
return i >= 0 && !args[i + 1]?.startsWith("--") ? args[i + 1] : undefined;
|
|
22
|
+
};
|
|
23
|
+
const has = (name) => args.includes(name) || args.some((a) => a.startsWith(`${name}=`));
|
|
24
|
+
const api = (process.env.INTERLOK_URL ??
|
|
25
|
+
process.env.INTERLOK_API_URL ??
|
|
26
|
+
value("--url") ??
|
|
27
|
+
"https://interlok.io").replace(/\/$/, "");
|
|
28
|
+
const key = process.env.INTERLOK_API_KEY ?? value("--api-key");
|
|
29
|
+
const headers = () => ({ authorization: `Bearer ${key}`, "content-type": "application/json" });
|
|
30
|
+
const gh = process.env;
|
|
31
|
+
/** Exit codes: 1 usage or error · 2 critical finding · 3 verification failed · 4 a --fail-on tripped · 5 coverage below --min-coverage. */
|
|
32
|
+
async function main() {
|
|
33
|
+
if (command === "scan") {
|
|
34
|
+
const target = args.find((a, i) => !a.startsWith("-") &&
|
|
35
|
+
!["--url", "--api-key", "--format", "--min-coverage"].includes(args[i - 1])) ?? ".";
|
|
36
|
+
const findings = await scan(target);
|
|
37
|
+
const found = await findInventory(target);
|
|
38
|
+
const files = found.files;
|
|
39
|
+
let inventory = found.inventory;
|
|
40
|
+
if (has("--enumerate"))
|
|
41
|
+
inventory = await enumerate(inventory);
|
|
42
|
+
const coverage = coverageOf(inventory);
|
|
43
|
+
const verdict = scanVerdict(coverage, inventory, coverageLine(coverage, "guarded"), {
|
|
44
|
+
filesFound: files.length > 0,
|
|
45
|
+
enumerated: has("--enumerate"),
|
|
46
|
+
});
|
|
47
|
+
if (value("--format") === "cef" || has("--format=cef")) {
|
|
48
|
+
findings.forEach((f) => console.log(`CEF:0|Interlok|CLI|1|${f.code}|${f.message}|${f.severity === "critical" ? 10 : 6}|file=${f.file}`));
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
console.log(JSON.stringify({
|
|
52
|
+
target,
|
|
53
|
+
inventory_files: files,
|
|
54
|
+
servers: inventory.servers.length,
|
|
55
|
+
servers_without_tools: unlistedServers(inventory),
|
|
56
|
+
coverage,
|
|
57
|
+
findings,
|
|
58
|
+
critical: findings.filter((f) => f.severity === "critical").length,
|
|
59
|
+
}, null, 2));
|
|
60
|
+
}
|
|
61
|
+
console.error(`coverage ${verdict.text}`);
|
|
62
|
+
if (has("--publish")) {
|
|
63
|
+
if (!api || !key)
|
|
64
|
+
throw new Error("--publish needs INTERLOK_URL and INTERLOK_API_KEY");
|
|
65
|
+
const s = await fetch(`${api}/api/v1/posture/scan`, {
|
|
66
|
+
method: "POST",
|
|
67
|
+
headers: headers(),
|
|
68
|
+
body: JSON.stringify({ input: inventory, source: has("--ci") ? "ci" : "cli", sync: true }),
|
|
69
|
+
});
|
|
70
|
+
if (!s.ok)
|
|
71
|
+
throw new Error(`scan upload failed (${s.status})`);
|
|
72
|
+
const { scan_id } = (await s.json());
|
|
73
|
+
const p = await fetch(`${api}/api/v1/public-reports`, {
|
|
74
|
+
method: "POST",
|
|
75
|
+
headers: headers(),
|
|
76
|
+
body: JSON.stringify({ kind: "posture", scan_id }),
|
|
77
|
+
});
|
|
78
|
+
if (!p.ok)
|
|
79
|
+
throw new Error(`publish failed (${p.status})`);
|
|
80
|
+
const pub = (await p.json());
|
|
81
|
+
console.error(`published ${pub.url}\nbadge ${pub.markdown}`);
|
|
82
|
+
}
|
|
83
|
+
if (has("--ci") && findings.some((f) => f.severity === "critical"))
|
|
84
|
+
process.exitCode = 2;
|
|
85
|
+
const min = value("--min-coverage");
|
|
86
|
+
if (min !== undefined) {
|
|
87
|
+
const pct = coverage.rungs.guarded?.pct ?? null;
|
|
88
|
+
// A CI gate must not pass on servers it could not see.
|
|
89
|
+
if (!verdict.measurable && inventory.servers.length) {
|
|
90
|
+
console.error(`coverage cannot be measured: ${verdict.text}`);
|
|
91
|
+
process.exitCode = 5;
|
|
92
|
+
}
|
|
93
|
+
else if (pct !== null && pct < Number(min)) {
|
|
94
|
+
console.error(`coverage ${pct}% is below --min-coverage ${min}`);
|
|
95
|
+
process.exitCode = 5;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (command === "verify") {
|
|
101
|
+
const path = args.find((a, i) => !a.startsWith("-") && !["--anchors-url", "--keys-url"].includes(args[i - 1]));
|
|
102
|
+
if (!path)
|
|
103
|
+
throw new Error("usage: interlok verify <report.json | bundle.ilproof>");
|
|
104
|
+
const raw = new Uint8Array(await readFile(path));
|
|
105
|
+
if (raw[0] === 0x50 && raw[1] === 0x4b) {
|
|
106
|
+
// A .ilproof bundle: the same seven checks the browser page runs.
|
|
107
|
+
const anchorsUrl = value("--anchors-url");
|
|
108
|
+
const keysUrl = value("--keys-url");
|
|
109
|
+
const publicAnchors = anchorsUrl
|
|
110
|
+
? ((await (await fetch(anchorsUrl)).json()).anchors ?? null)
|
|
111
|
+
: null;
|
|
112
|
+
const kj = keysUrl ? await (await fetch(keysUrl)).json() : null;
|
|
113
|
+
const publishedKids = kj
|
|
114
|
+
? (Array.isArray(kj) ? kj : (kj.keys ?? [])).map((k) => typeof k === "string" ? k : k.kid)
|
|
115
|
+
: null;
|
|
116
|
+
const r = await verifyProofBundle(raw, { publicAnchors, publishedKids });
|
|
117
|
+
console.log(`${r.ok ? "verified" : "verification failed"}: ${r.record_count} records · bundle ${r.bundle_id ?? "?"}`);
|
|
118
|
+
for (const c of r.checks) {
|
|
119
|
+
console.log(` ${c.status === "pass" ? "✓" : c.status === "fail" ? "✗" : "–"} ${c.id}. ${c.name}: ${c.detail}`);
|
|
120
|
+
for (const p of c.records)
|
|
121
|
+
console.log(` ${p.seq !== null ? `#${p.seq} ` : ""}${p.problem}`);
|
|
122
|
+
}
|
|
123
|
+
if (!r.ok)
|
|
124
|
+
process.exitCode = 3;
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const result = await verifyFile(path);
|
|
128
|
+
console.log(result.ok
|
|
129
|
+
? `verified ${result.checked} signed entr${result.checked === 1 ? "y" : "ies"} offline${result.detail ? `; ${result.detail}` : ""}`
|
|
130
|
+
: `verification failed at #${result.seq}: ${result.reason}`);
|
|
131
|
+
if (!result.ok)
|
|
132
|
+
process.exitCode = 3;
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (command === "shadow") {
|
|
136
|
+
if (!key)
|
|
137
|
+
throw new Error("INTERLOK_API_KEY is required (INTERLOK_URL defaults to https://interlok.io)");
|
|
138
|
+
const sep = args.indexOf("--");
|
|
139
|
+
const childArgs = sep >= 0 ? args.slice(sep + 1) : [];
|
|
140
|
+
// In GitHub Actions these come from the environment: the commit, the branch it was built from,
|
|
141
|
+
// and, on a pull request, the branch it will merge into, which is what it is compared with.
|
|
142
|
+
const safe = (v) => (v ? v.replace(/[^\w./@+-]+/g, "_").slice(0, 200) : undefined);
|
|
143
|
+
const git_ref = safe(value("--git-ref") ?? gh.GITHUB_SHA);
|
|
144
|
+
const git_branch = safe(value("--branch") ?? (gh.GITHUB_HEAD_REF || gh.GITHUB_REF_NAME || undefined));
|
|
145
|
+
const baseline = safe(value("--baseline") ?? (gh.GITHUB_BASE_REF || undefined));
|
|
146
|
+
const start = await fetch(`${api}/api/v1/shadow/runs`, {
|
|
147
|
+
method: "POST",
|
|
148
|
+
headers: headers(),
|
|
149
|
+
body: JSON.stringify({
|
|
150
|
+
agent_name: value("--agent") ?? "cli-agent",
|
|
151
|
+
name: value("--name") ??
|
|
152
|
+
(git_branch
|
|
153
|
+
? `${git_branch}${git_ref ? ` @ ${git_ref.slice(0, 7)}` : ""}`
|
|
154
|
+
: "CLI shadow run"),
|
|
155
|
+
scope: [],
|
|
156
|
+
git_ref,
|
|
157
|
+
git_branch,
|
|
158
|
+
baseline,
|
|
159
|
+
}),
|
|
160
|
+
});
|
|
161
|
+
if (!start.ok)
|
|
162
|
+
throw new Error(`shadow start failed (${start.status})`);
|
|
163
|
+
const run = (await start.json());
|
|
164
|
+
let exit = 0;
|
|
165
|
+
if (childArgs.length)
|
|
166
|
+
exit = await new Promise((resolve, reject) => {
|
|
167
|
+
const p = spawn(childArgs[0], childArgs.slice(1), {
|
|
168
|
+
stdio: "inherit",
|
|
169
|
+
env: {
|
|
170
|
+
...process.env,
|
|
171
|
+
INTERLOK_SHADOW_RUN: run.id,
|
|
172
|
+
INTERLOK_API_URL: api,
|
|
173
|
+
INTERLOK_API_KEY: key,
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
p.on("error", reject);
|
|
177
|
+
p.on("exit", (c) => resolve(c ?? 1));
|
|
178
|
+
});
|
|
179
|
+
const done = await fetch(`${api}/api/v1/shadow/runs/${run.id}/finish`, {
|
|
180
|
+
method: "POST",
|
|
181
|
+
headers: headers(),
|
|
182
|
+
});
|
|
183
|
+
if (!done.ok)
|
|
184
|
+
throw new Error(`shadow finish failed (${done.status})`);
|
|
185
|
+
const reportRes = await fetch(`${api}/api/v1/shadow/runs/${run.id}/report`, {
|
|
186
|
+
headers: headers(),
|
|
187
|
+
});
|
|
188
|
+
if (!reportRes.ok)
|
|
189
|
+
throw new Error(`shadow report failed (${reportRes.status})`);
|
|
190
|
+
const report = (await reportRes.json());
|
|
191
|
+
const out = value("--out") ?? `interlok-shadow-${run.id}.json`;
|
|
192
|
+
await writeFile(out, JSON.stringify(report, null, 2));
|
|
193
|
+
console.log(`shadow ${run.id}: ${report.run.verdict} · signed report ${out}`);
|
|
194
|
+
if (report.markdown) {
|
|
195
|
+
const md = value("--markdown");
|
|
196
|
+
if (md)
|
|
197
|
+
await writeFile(md, report.markdown + "\n");
|
|
198
|
+
// Shows on the Actions run page with no token and no GitHub App.
|
|
199
|
+
if (gh.GITHUB_STEP_SUMMARY)
|
|
200
|
+
await appendFile(gh.GITHUB_STEP_SUMMARY, report.markdown + "\n");
|
|
201
|
+
}
|
|
202
|
+
// --fail-on would_hold,new_high_reach,coverage_drop (comma-separated; the old --fail-on=would_hold still works)
|
|
203
|
+
const failOn = (value("--fail-on") ?? "")
|
|
204
|
+
.split(",")
|
|
205
|
+
.map((s) => s.trim())
|
|
206
|
+
.filter(Boolean);
|
|
207
|
+
const tripped = [];
|
|
208
|
+
if (failOn.includes("would_hold") && report.events.some((e) => e.would_effect !== "allow"))
|
|
209
|
+
tripped.push("would_hold");
|
|
210
|
+
const d = report.diff;
|
|
211
|
+
if (failOn.includes("new_high_reach") &&
|
|
212
|
+
d &&
|
|
213
|
+
(d.new_high_risk_paths.length > 0 || d.reach_up.some((r) => r.to === "R2" || r.to === "R3")))
|
|
214
|
+
tripped.push("new_high_reach");
|
|
215
|
+
if (failOn.includes("coverage_drop") &&
|
|
216
|
+
d?.coverage_delta.governed &&
|
|
217
|
+
(d.coverage_delta.governed.to ?? 0) < (d.coverage_delta.governed.from ?? 0))
|
|
218
|
+
tripped.push("coverage_drop");
|
|
219
|
+
if (tripped.length) {
|
|
220
|
+
console.error(`failing on: ${tripped.join(", ")}`);
|
|
221
|
+
process.exitCode = 4;
|
|
222
|
+
}
|
|
223
|
+
else
|
|
224
|
+
process.exitCode = exit;
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (command === "gate")
|
|
228
|
+
return gateCommand(args, value, has);
|
|
229
|
+
if (command === "proxy")
|
|
230
|
+
return proxyCommand(args, value, has);
|
|
231
|
+
if (command === "approvals-mcp")
|
|
232
|
+
return approvalsMcpCommand(value);
|
|
233
|
+
if (command === "hook")
|
|
234
|
+
return hookCommand(args, value, has);
|
|
235
|
+
console.error("usage: interlok <scan|shadow|proxy|gate|verify|approvals-mcp|hook>");
|
|
236
|
+
process.exitCode = 1;
|
|
237
|
+
}
|
|
238
|
+
main().catch((e) => {
|
|
239
|
+
console.error(e.message);
|
|
240
|
+
process.exitCode = 1;
|
|
241
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { type Coverage, type ToolAnnotations } from "./classify.js";
|
|
2
|
+
/**
|
|
3
|
+
* Coverage for `interlok scan`. Reads MCP client configs (Claude, Cursor, VS Code, .mcp.json) or an
|
|
4
|
+
* inventory JSON, and grades every listed tool with the same classifier the console uses. With
|
|
5
|
+
* --enumerate it starts each server and asks it for its tools; otherwise it only reads files, and
|
|
6
|
+
* nothing leaves the machine unless --publish is passed.
|
|
7
|
+
*/
|
|
8
|
+
export interface InvTool {
|
|
9
|
+
name: string;
|
|
10
|
+
description?: string;
|
|
11
|
+
input_schema?: unknown;
|
|
12
|
+
annotations?: ToolAnnotations;
|
|
13
|
+
gated?: boolean;
|
|
14
|
+
write_path?: boolean;
|
|
15
|
+
}
|
|
16
|
+
export interface InvServer {
|
|
17
|
+
name: string;
|
|
18
|
+
command?: string;
|
|
19
|
+
args?: string[];
|
|
20
|
+
url?: string;
|
|
21
|
+
transport?: string;
|
|
22
|
+
auth?: string;
|
|
23
|
+
registered?: boolean;
|
|
24
|
+
tools?: InvTool[];
|
|
25
|
+
error?: string;
|
|
26
|
+
}
|
|
27
|
+
export interface Inventory {
|
|
28
|
+
servers: InvServer[];
|
|
29
|
+
[k: string]: unknown;
|
|
30
|
+
}
|
|
31
|
+
export declare function findInventory(target: string): Promise<{
|
|
32
|
+
files: string[];
|
|
33
|
+
inventory: Inventory;
|
|
34
|
+
}>;
|
|
35
|
+
/** The same normalisation the console uses: an inventory passes through; a client config is mapped. */
|
|
36
|
+
export declare function toInventory(raw: any): Inventory;
|
|
37
|
+
/** Servers whose tools are not listed in the inventory, so coverage cannot see them. */
|
|
38
|
+
export declare function unlistedServers(inv: Inventory): string[];
|
|
39
|
+
/**
|
|
40
|
+
* The one-line verdict printed after a scan. "No high-risk write paths" is only said when every
|
|
41
|
+
* server's tools were actually seen; a config that names servers without listing their tools gets
|
|
42
|
+
* told to enumerate instead of being told it is safe.
|
|
43
|
+
*/
|
|
44
|
+
export declare function scanVerdict(coverage: Coverage, inv: Inventory, line: string, opts: {
|
|
45
|
+
filesFound: boolean;
|
|
46
|
+
enumerated: boolean;
|
|
47
|
+
}): {
|
|
48
|
+
text: string;
|
|
49
|
+
measurable: boolean;
|
|
50
|
+
};
|
|
51
|
+
export declare function coverageOf(inv: Inventory): Coverage;
|
|
52
|
+
/** Start each server (or call it over HTTP) and list its tools. Opt-in: this runs the configured commands. */
|
|
53
|
+
export declare function enumerate(inv: Inventory, timeoutMs?: number): Promise<Inventory>;
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { existsSync, statSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { classifyTool, computeCoverage } from "./classify.js";
|
|
6
|
+
const CONFIG_FILES = [
|
|
7
|
+
".mcp.json",
|
|
8
|
+
".vscode/mcp.json",
|
|
9
|
+
".cursor/mcp.json",
|
|
10
|
+
"interlok-inventory.json",
|
|
11
|
+
];
|
|
12
|
+
export async function findInventory(target) {
|
|
13
|
+
const files = !existsSync(target)
|
|
14
|
+
? []
|
|
15
|
+
: statSync(target).isDirectory()
|
|
16
|
+
? CONFIG_FILES.map((f) => join(target, f)).filter((f) => existsSync(f))
|
|
17
|
+
: [target];
|
|
18
|
+
const servers = [];
|
|
19
|
+
const used = [];
|
|
20
|
+
for (const f of files) {
|
|
21
|
+
let raw;
|
|
22
|
+
try {
|
|
23
|
+
raw = JSON.parse(await readFile(f, "utf8"));
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
const inv = toInventory(raw);
|
|
29
|
+
if (inv.servers.length) {
|
|
30
|
+
used.push(f);
|
|
31
|
+
servers.push(...inv.servers);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return { files: used, inventory: { servers } };
|
|
35
|
+
}
|
|
36
|
+
/** The same normalisation the console uses: an inventory passes through; a client config is mapped. */
|
|
37
|
+
export function toInventory(raw) {
|
|
38
|
+
if (Array.isArray(raw?.servers) && raw.servers.some((s) => s?.name))
|
|
39
|
+
return raw;
|
|
40
|
+
const map = raw?.mcpServers ?? raw?.servers ?? raw?.mcp?.servers ?? {};
|
|
41
|
+
return {
|
|
42
|
+
servers: Object.entries(map).map(([name, v]) => ({
|
|
43
|
+
name,
|
|
44
|
+
...(v?.url
|
|
45
|
+
? { url: v.url, transport: v?.type === "sse" ? "sse" : "streamable-http" }
|
|
46
|
+
: { transport: "stdio" }),
|
|
47
|
+
...(v?.command ? { command: v.command, args: v.args ?? [] } : {}),
|
|
48
|
+
registered: false,
|
|
49
|
+
auth: v?.headers?.authorization || v?.headers?.Authorization || v?.env?.API_KEY || v?.env?.TOKEN
|
|
50
|
+
? "token"
|
|
51
|
+
: "none",
|
|
52
|
+
...(Array.isArray(v?.tools) ? { tools: v.tools } : {}),
|
|
53
|
+
})),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/** Servers whose tools are not listed in the inventory, so coverage cannot see them. */
|
|
57
|
+
export function unlistedServers(inv) {
|
|
58
|
+
return inv.servers.filter((s) => !s.tools?.length).map((s) => s.name);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The one-line verdict printed after a scan. "No high-risk write paths" is only said when every
|
|
62
|
+
* server's tools were actually seen; a config that names servers without listing their tools gets
|
|
63
|
+
* told to enumerate instead of being told it is safe.
|
|
64
|
+
*/
|
|
65
|
+
export function scanVerdict(coverage, inv, line, opts) {
|
|
66
|
+
if (!opts.filesFound)
|
|
67
|
+
return {
|
|
68
|
+
text: "no MCP config or inventory found; pass a file, or --enumerate to list tools",
|
|
69
|
+
measurable: false,
|
|
70
|
+
};
|
|
71
|
+
const unlisted = unlistedServers(inv);
|
|
72
|
+
const n = inv.servers.length;
|
|
73
|
+
if (!unlisted.length)
|
|
74
|
+
return { text: line, measurable: true };
|
|
75
|
+
const names = unlisted.slice(0, 5).join(", ") + (unlisted.length > 5 ? ", …" : "");
|
|
76
|
+
const hint = opts.enumerated
|
|
77
|
+
? "they did not answer when started; check their commands and credentials"
|
|
78
|
+
: "run with --enumerate to start them and check their tools";
|
|
79
|
+
if (unlisted.length === n)
|
|
80
|
+
return {
|
|
81
|
+
text: `${n} server${n === 1 ? "" : "s"} found (${names}), tools not listed: ${hint}`,
|
|
82
|
+
measurable: false,
|
|
83
|
+
};
|
|
84
|
+
return {
|
|
85
|
+
text: `${line} · ${unlisted.length} of ${n} servers not checked (${names}): ${hint}`,
|
|
86
|
+
measurable: coverage.high_risk_write_paths > 0,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
export function coverageOf(inv) {
|
|
90
|
+
const items = [];
|
|
91
|
+
for (const s of inv.servers)
|
|
92
|
+
for (const t of s.tools ?? [])
|
|
93
|
+
items.push({
|
|
94
|
+
key: `${s.name}/${t.name}`,
|
|
95
|
+
profile: classifyTool({
|
|
96
|
+
name: t.name,
|
|
97
|
+
description: t.description,
|
|
98
|
+
input_schema: t.input_schema,
|
|
99
|
+
annotations: t.annotations,
|
|
100
|
+
write_path: t.write_path,
|
|
101
|
+
}),
|
|
102
|
+
guarded: !!t.gated,
|
|
103
|
+
});
|
|
104
|
+
return computeCoverage(items);
|
|
105
|
+
}
|
|
106
|
+
const INIT = {
|
|
107
|
+
jsonrpc: "2.0",
|
|
108
|
+
id: 1,
|
|
109
|
+
method: "initialize",
|
|
110
|
+
params: {
|
|
111
|
+
protocolVersion: "2025-06-18",
|
|
112
|
+
capabilities: {},
|
|
113
|
+
clientInfo: { name: "interlok-cli", version: "1.1.0" },
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
const LIST = { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} };
|
|
117
|
+
const toTool = (t) => ({
|
|
118
|
+
name: t.name,
|
|
119
|
+
description: t.description,
|
|
120
|
+
input_schema: t.inputSchema,
|
|
121
|
+
...(t.annotations ? { annotations: t.annotations } : {}),
|
|
122
|
+
});
|
|
123
|
+
/** Start each server (or call it over HTTP) and list its tools. Opt-in: this runs the configured commands. */
|
|
124
|
+
export async function enumerate(inv, timeoutMs = 15_000) {
|
|
125
|
+
const servers = [];
|
|
126
|
+
for (const s of inv.servers) {
|
|
127
|
+
if (s.tools?.length) {
|
|
128
|
+
servers.push(s);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
servers.push({
|
|
133
|
+
...s,
|
|
134
|
+
tools: s.url ? await overHttp(s.url, timeoutMs) : await overStdio(s, timeoutMs),
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
catch (e) {
|
|
138
|
+
servers.push({ ...s, error: e?.message ?? String(e) });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return { ...inv, servers };
|
|
142
|
+
}
|
|
143
|
+
function overStdio(s, timeoutMs) {
|
|
144
|
+
if (!s.command)
|
|
145
|
+
return Promise.reject(new Error("no command to start"));
|
|
146
|
+
const child = spawn(s.command, s.args ?? [], {
|
|
147
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
148
|
+
env: { ...process.env, MCP_SCAN: "1" },
|
|
149
|
+
});
|
|
150
|
+
return new Promise((resolve, reject) => {
|
|
151
|
+
let buf = "";
|
|
152
|
+
const done = (fn) => {
|
|
153
|
+
clearTimeout(timer);
|
|
154
|
+
child.kill("SIGTERM");
|
|
155
|
+
fn();
|
|
156
|
+
};
|
|
157
|
+
const timer = setTimeout(() => done(() => reject(new Error(`timed out after ${timeoutMs}ms`))), timeoutMs);
|
|
158
|
+
child.on("error", (e) => done(() => reject(e)));
|
|
159
|
+
child.stdout.on("data", (chunk) => {
|
|
160
|
+
buf += chunk.toString();
|
|
161
|
+
let nl;
|
|
162
|
+
while ((nl = buf.indexOf("\n")) >= 0) {
|
|
163
|
+
const line = buf.slice(0, nl).trim();
|
|
164
|
+
buf = buf.slice(nl + 1);
|
|
165
|
+
if (!line)
|
|
166
|
+
continue;
|
|
167
|
+
let msg;
|
|
168
|
+
try {
|
|
169
|
+
msg = JSON.parse(line);
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (msg.id === 1) {
|
|
175
|
+
child.stdin.write(JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }) + "\n");
|
|
176
|
+
child.stdin.write(JSON.stringify(LIST) + "\n");
|
|
177
|
+
}
|
|
178
|
+
if (msg.id === 2) {
|
|
179
|
+
if (msg.error)
|
|
180
|
+
return done(() => reject(new Error(msg.error.message ?? "tools/list failed")));
|
|
181
|
+
return done(() => resolve((msg.result?.tools ?? []).map(toTool)));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
child.stdin.write(JSON.stringify(INIT) + "\n");
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
async function overHttp(url, timeoutMs) {
|
|
189
|
+
const signal = AbortSignal.timeout(timeoutMs);
|
|
190
|
+
const headers = {
|
|
191
|
+
"content-type": "application/json",
|
|
192
|
+
accept: "application/json, text/event-stream",
|
|
193
|
+
};
|
|
194
|
+
const init = await fetch(url, { method: "POST", headers, body: JSON.stringify(INIT), signal });
|
|
195
|
+
const sid = init.headers.get("mcp-session-id");
|
|
196
|
+
if (sid)
|
|
197
|
+
headers["mcp-session-id"] = sid;
|
|
198
|
+
const res = await fetch(url, { method: "POST", headers, body: JSON.stringify(LIST), signal });
|
|
199
|
+
const text = await res.text();
|
|
200
|
+
const payload = text.startsWith("event:") || text.startsWith("data:")
|
|
201
|
+
? JSON.parse(text
|
|
202
|
+
.split("\n")
|
|
203
|
+
.filter((l) => l.startsWith("data:"))
|
|
204
|
+
.map((l) => l.slice(5).trim())
|
|
205
|
+
.join(""))
|
|
206
|
+
: JSON.parse(text);
|
|
207
|
+
if (payload.error)
|
|
208
|
+
throw new Error(payload.error.message ?? "tools/list failed");
|
|
209
|
+
return (payload.result?.tools ?? []).map(toTool);
|
|
210
|
+
}
|
package/dist/packs.d.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The change-control-v1 (with its model-change rules) and access-control-v1 packs: deterministic predicates over a normalized
|
|
3
|
+
* request. A request is held when any rule with `hold: true` fires or its points reach the
|
|
4
|
+
* threshold (50). Nothing here calls a model or the network, and the same input always yields the
|
|
5
|
+
* same rules, so a CI run, the console and a test agree.
|
|
6
|
+
*/
|
|
7
|
+
export declare const HOLD_THRESHOLD = 50;
|
|
8
|
+
export interface PackResult {
|
|
9
|
+
hold: boolean;
|
|
10
|
+
points: number;
|
|
11
|
+
fired: Array<{
|
|
12
|
+
id: string;
|
|
13
|
+
reason: string;
|
|
14
|
+
points: number;
|
|
15
|
+
hold: boolean;
|
|
16
|
+
}>;
|
|
17
|
+
}
|
|
18
|
+
export interface PlanSummary {
|
|
19
|
+
add: number;
|
|
20
|
+
change: number;
|
|
21
|
+
destroy: number;
|
|
22
|
+
replace: number;
|
|
23
|
+
resource_types: string[];
|
|
24
|
+
/** Resource addresses, used for IAM / network / data-store detection. */
|
|
25
|
+
addresses?: string[];
|
|
26
|
+
/** Security-group or firewall rules that open the world. */
|
|
27
|
+
open_cidrs?: string[];
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* A model promotion: what a production AI system will run after this change. Swapping a provider,
|
|
31
|
+
* a model, its weights, its quantization or its prompt template changes behaviour as surely as a
|
|
32
|
+
* code deploy, and usually without a diff anyone reads.
|
|
33
|
+
*/
|
|
34
|
+
export declare const MODEL_CHANGE_KINDS: readonly ["model", "provider", "weights", "fine_tune", "quantization", "prompt_template", "decoding", "alias"];
|
|
35
|
+
export type ModelChangeKind = (typeof MODEL_CHANGE_KINDS)[number];
|
|
36
|
+
export interface ModelChange {
|
|
37
|
+
provider_from?: string | null;
|
|
38
|
+
provider_to?: string | null;
|
|
39
|
+
model_from?: string | null;
|
|
40
|
+
model_to: string;
|
|
41
|
+
/** sha256 of weights or image, when the platform can state one. */
|
|
42
|
+
digest_from?: string | null;
|
|
43
|
+
digest_to?: string | null;
|
|
44
|
+
changes: ModelChangeKind[];
|
|
45
|
+
/** The evaluation run gating this promotion, if any. */
|
|
46
|
+
eval?: {
|
|
47
|
+
suite?: string | null;
|
|
48
|
+
passed?: boolean | null;
|
|
49
|
+
regressions?: number | null;
|
|
50
|
+
} | null;
|
|
51
|
+
/** Data classes the model will serve (e.g. cui, phi, pii, credit). */
|
|
52
|
+
classification?: string[];
|
|
53
|
+
}
|
|
54
|
+
export interface ChangeContext {
|
|
55
|
+
action: "deploy" | "terraform.apply" | "model.promote" | "pr.merge";
|
|
56
|
+
environment?: string | null;
|
|
57
|
+
repo?: string | null;
|
|
58
|
+
pr_number?: number | null;
|
|
59
|
+
requester?: string | null;
|
|
60
|
+
/** Kind of the PR author and every commit author: human | agent | bot. */
|
|
61
|
+
author_kinds: string[];
|
|
62
|
+
pr_approved_by_human?: boolean | null;
|
|
63
|
+
changed_paths?: string[];
|
|
64
|
+
files_changed?: number | null;
|
|
65
|
+
migration_globs?: string[];
|
|
66
|
+
plan?: PlanSummary | null;
|
|
67
|
+
model?: ModelChange | null;
|
|
68
|
+
/** Org settings the connector carries. */
|
|
69
|
+
freeze_active?: boolean;
|
|
70
|
+
in_change_window?: boolean | null;
|
|
71
|
+
production_tags?: string[];
|
|
72
|
+
}
|
|
73
|
+
export declare function evaluateChange(c: ChangeContext, overrides?: {
|
|
74
|
+
agent_author_points?: number | null;
|
|
75
|
+
}): PackResult;
|
|
76
|
+
export interface AccessContext {
|
|
77
|
+
requester_kind: "human" | "agent" | "service_account";
|
|
78
|
+
requester_has_owner?: boolean | null;
|
|
79
|
+
resource_system: string;
|
|
80
|
+
resource_id: string;
|
|
81
|
+
sensitivity: "low" | "medium" | "high";
|
|
82
|
+
role: string;
|
|
83
|
+
is_production?: boolean | null;
|
|
84
|
+
duration_requested_s: number;
|
|
85
|
+
ticket_ref?: string | null;
|
|
86
|
+
/** Elevations by the same requester on the same resource in the past 7 days. */
|
|
87
|
+
recent_elevations?: number;
|
|
88
|
+
hour_local?: number | null;
|
|
89
|
+
business_hours?: [number, number];
|
|
90
|
+
}
|
|
91
|
+
export declare const MAX_DURATION_S: Record<AccessContext["sensitivity"], number>;
|
|
92
|
+
export declare function evaluateAccess(c: AccessContext): PackResult & {
|
|
93
|
+
deny: boolean;
|
|
94
|
+
cap_s: number;
|
|
95
|
+
};
|
|
96
|
+
/** `terraform show -json` → the summary the CLI sends. Runs in CI; the full plan never leaves it. */
|
|
97
|
+
export declare function summarizePlan(plan: any): PlanSummary;
|
|
98
|
+
/** CLI exit codes for the gate step. */
|
|
99
|
+
export declare const EXIT: {
|
|
100
|
+
readonly ok: 0;
|
|
101
|
+
readonly rejected: 10;
|
|
102
|
+
readonly expired: 11;
|
|
103
|
+
readonly unreachable: 12;
|
|
104
|
+
readonly config: 13;
|
|
105
|
+
};
|