@inner-security/mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,883 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/server.ts
4
+ import { realpathSync } from "fs";
5
+ import { pathToFileURL } from "url";
6
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
8
+ import { z } from "zod";
9
+
10
+ // ../contracts/dist/enums.js
11
+ var ECOSYSTEM = ["npm", "pypi"];
12
+ var STATUS_TO_ACTION = {
13
+ benign: "ALLOW",
14
+ suspicious: "REVIEW",
15
+ malicious: "BLOCK",
16
+ unknown: "PENDING"
17
+ };
18
+
19
+ // ../contracts/dist/fixtures.js
20
+ var NOW = "2026-06-27T00:00:00Z";
21
+ var WEEK = "2026-07-04T00:00:00Z";
22
+ function v(p) {
23
+ const status = p.status;
24
+ return {
25
+ ecosystem: "npm",
26
+ action: STATUS_TO_ACTION[status],
27
+ confidence: status === "malicious" ? 0.97 : status === "suspicious" ? 0.6 : 0.95,
28
+ behaviors: [],
29
+ corroboration: "behavioral_only",
30
+ evidence_uri: `s3://inner-evidence/${p.name}@${p.version}.json`,
31
+ engine_version: "v0.1.0",
32
+ scanned_at: NOW,
33
+ expires_at: WEEK,
34
+ engine_signature: "ed25519:STUB",
35
+ version_id: `${p.name}@${p.version}`,
36
+ ...p
37
+ };
38
+ }
39
+ var SAMPLE_VERDICTS = [
40
+ // --- 10 malicious ---
41
+ // event-stream-style: real-world supply-chain attack (flatmap-stream payload).
42
+ v({
43
+ name: "event-stream",
44
+ version: "3.3.6",
45
+ status: "malicious",
46
+ content_hash: "sha256:bad1",
47
+ corroboration: "corroborated",
48
+ behaviors: [
49
+ { category: "credential_theft", severity: "critical", evidence_ref: "trace#1" },
50
+ { category: "c2_communication", severity: "high", evidence_ref: "trace#2" }
51
+ ],
52
+ capabilities: [
53
+ { capability: "reads_credentials", source: "hook" },
54
+ { capability: "network_exfil", source: "network" }
55
+ ]
56
+ }),
57
+ // Typosquat of 'react'.
58
+ v({
59
+ name: "reaact",
60
+ version: "1.0.0",
61
+ status: "malicious",
62
+ content_hash: "sha256:bad2",
63
+ corroboration: "corroborated",
64
+ behaviors: [{ category: "malicious_download", severity: "high", evidence_ref: "trace#1" }],
65
+ capabilities: [{ capability: "self_propagation", source: "static" }]
66
+ }),
67
+ // Behavioral-only: spawns shell during install. Behavioral_only → REVIEW until eval-certified.
68
+ v({
69
+ name: "left-pad-2",
70
+ version: "0.0.1",
71
+ status: "malicious",
72
+ content_hash: "sha256:bad3",
73
+ corroboration: "behavioral_only",
74
+ behaviors: [{ category: "command_execution", severity: "high", evidence_ref: "trace#1" }],
75
+ capabilities: [{ capability: "spawns_process", source: "syscall" }]
76
+ }),
77
+ // Postinstall script reads ~/.aws/credentials and POSTs to an external host.
78
+ v({
79
+ name: "aws-helper-cli",
80
+ version: "0.4.2",
81
+ status: "malicious",
82
+ content_hash: "sha256:bad4",
83
+ corroboration: "corroborated",
84
+ behaviors: [
85
+ { category: "credential_theft", severity: "critical", evidence_ref: "trace#1" },
86
+ { category: "data_exfiltration", severity: "critical", evidence_ref: "trace#2" }
87
+ ],
88
+ capabilities: [
89
+ { capability: "reads_credentials", source: "syscall" },
90
+ { capability: "network_exfil", source: "network" }
91
+ ]
92
+ }),
93
+ // Obfuscated eval(atob(...)) payload — dynamic code execution detected statically.
94
+ v({
95
+ name: "kazka-utils",
96
+ version: "2.1.0",
97
+ status: "malicious",
98
+ content_hash: "sha256:bad5",
99
+ corroboration: "behavioral_only",
100
+ behaviors: [{ category: "dynamic_code_execution", severity: "high", evidence_ref: "trace#1" }],
101
+ capabilities: [{ capability: "obfuscated_payload", source: "static" }]
102
+ }),
103
+ // PyPI: setup.py performs persistence write to ~/.bashrc.
104
+ v({
105
+ ecosystem: "pypi",
106
+ name: "colorama-utils",
107
+ version: "0.4.5",
108
+ status: "malicious",
109
+ content_hash: "sha256:bad6",
110
+ corroboration: "behavioral_only",
111
+ behaviors: [{ category: "persistence", severity: "high", evidence_ref: "trace#1" }],
112
+ capabilities: [{ capability: "persistence", source: "syscall" }]
113
+ }),
114
+ // C2 beacon over WebSocket on a regular interval — slow-lane confirmed.
115
+ v({
116
+ name: "discord-bot-helper",
117
+ version: "0.0.7",
118
+ status: "malicious",
119
+ content_hash: "sha256:bad7",
120
+ corroboration: "corroborated",
121
+ behaviors: [{ category: "c2_communication", severity: "critical", evidence_ref: "trace#1" }],
122
+ capabilities: [{ capability: "c2_beacon", source: "network" }]
123
+ }),
124
+ // PyPI typosquat: 'requrest' -> 'requests'. Reads env vars and writes /tmp/.x.
125
+ v({
126
+ ecosystem: "pypi",
127
+ name: "requrest",
128
+ version: "2.31.0",
129
+ status: "malicious",
130
+ content_hash: "sha256:bad8",
131
+ corroboration: "corroborated",
132
+ behaviors: [
133
+ { category: "data_collection", severity: "medium", evidence_ref: "trace#1" },
134
+ { category: "file_manipulation", severity: "medium", evidence_ref: "trace#2" }
135
+ ],
136
+ capabilities: [
137
+ { capability: "env_recon", source: "hook" },
138
+ { capability: "modifies_files", source: "syscall" }
139
+ ]
140
+ }),
141
+ // Reverse shell binding /bin/sh to a remote port during import.
142
+ v({
143
+ name: "fastly-cdn-uploader",
144
+ version: "1.0.0",
145
+ status: "malicious",
146
+ content_hash: "sha256:bad9",
147
+ corroboration: "corroborated",
148
+ behaviors: [{ category: "reverse_shell", severity: "critical", evidence_ref: "trace#1" }],
149
+ capabilities: [
150
+ { capability: "spawns_process", source: "syscall" },
151
+ { capability: "network_exfil", source: "network" }
152
+ ]
153
+ }),
154
+ // Web-injection payload: serves modified content on import; common in browser-side attacks.
155
+ v({
156
+ name: "analytics-tracker-pro",
157
+ version: "3.0.0",
158
+ status: "malicious",
159
+ content_hash: "sha256:bad10",
160
+ corroboration: "behavioral_only",
161
+ behaviors: [{ category: "web_injection", severity: "medium", evidence_ref: "trace#1" }]
162
+ }),
163
+ // --- 10 benign ---
164
+ v({ name: "react", version: "18.3.1", status: "benign", content_hash: "sha256:ok1" }),
165
+ v({ name: "lodash", version: "4.17.21", status: "benign", content_hash: "sha256:ok2" }),
166
+ v({ name: "axios", version: "1.7.2", status: "benign", content_hash: "sha256:ok3" }),
167
+ v({ name: "typescript", version: "5.5.4", status: "benign", content_hash: "sha256:ok4" }),
168
+ v({ name: "express", version: "4.19.2", status: "benign", content_hash: "sha256:ok5" }),
169
+ v({ name: "vite", version: "5.4.0", status: "benign", content_hash: "sha256:ok6" }),
170
+ // Heavy native build (node-gyp). Long install time is legitimate, not malicious.
171
+ v({ name: "node-sass", version: "9.0.0", status: "benign", content_hash: "sha256:ok7" }),
172
+ v({ ecosystem: "pypi", name: "requests", version: "2.31.0", status: "benign", content_hash: "sha256:ok8" }),
173
+ v({ ecosystem: "pypi", name: "numpy", version: "1.26.4", status: "benign", content_hash: "sha256:ok9" }),
174
+ v({ ecosystem: "pypi", name: "pandas", version: "2.2.2", status: "benign", content_hash: "sha256:ok10" })
175
+ ];
176
+
177
+ // ../verdict-client/dist/client.js
178
+ var DEFAULT_API_URL = "http://localhost:8787";
179
+ function isPending(item) {
180
+ return item.action === "PENDING";
181
+ }
182
+ function isEnvelope(item) {
183
+ return !isPending(item);
184
+ }
185
+ function sleep(ms) {
186
+ return new Promise((resolve) => setTimeout(resolve, ms));
187
+ }
188
+ function stripTrailingSlash(u) {
189
+ return u.replace(/\/+$/, "");
190
+ }
191
+ var VerdictClient = class {
192
+ baseUrl;
193
+ apiKey;
194
+ timeoutMs;
195
+ maxPollAttempts;
196
+ maxRetries;
197
+ fetchImpl;
198
+ cache;
199
+ constructor(opts = {}) {
200
+ this.baseUrl = stripTrailingSlash(opts.baseUrl ?? process.env.INNER_API_URL ?? DEFAULT_API_URL);
201
+ this.apiKey = opts.apiKey ?? process.env.INNER_API_KEY;
202
+ this.timeoutMs = opts.timeoutMs ?? 1e4;
203
+ this.maxPollAttempts = opts.maxPollAttempts ?? 8;
204
+ this.maxRetries = opts.maxRetries ?? 2;
205
+ this.fetchImpl = opts.fetchImpl ?? globalThis.fetch;
206
+ this.cache = opts.cache;
207
+ if (typeof this.fetchImpl !== "function") {
208
+ throw new Error("No fetch implementation available (Node >= 18 required, or pass fetchImpl).");
209
+ }
210
+ }
211
+ headers() {
212
+ const h = { "content-type": "application/json" };
213
+ if (this.apiKey)
214
+ h.authorization = `Bearer ${this.apiKey}`;
215
+ return h;
216
+ }
217
+ /** A single fetch with a per-request AbortController timeout. */
218
+ async fetchOnce(url, init) {
219
+ const ctrl = new AbortController();
220
+ const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
221
+ try {
222
+ return await this.fetchImpl(url, { ...init, signal: ctrl.signal });
223
+ } finally {
224
+ clearTimeout(timer);
225
+ }
226
+ }
227
+ /**
228
+ * fetch with timeout + retries-with-backoff on transient failures (network
229
+ * error / timeout / 5xx). 404 is returned as-is (the poll path treats it as
230
+ * "not ready yet"); other 4xx are returned as-is for the caller to handle.
231
+ */
232
+ async fetchWithRetry(url, init) {
233
+ let lastErr;
234
+ let wait = 250;
235
+ for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
236
+ try {
237
+ const res = await this.fetchOnce(url, init);
238
+ if (res.status >= 500 && attempt < this.maxRetries) {
239
+ await sleep(wait);
240
+ wait = Math.min(wait * 2, 5e3);
241
+ continue;
242
+ }
243
+ return res;
244
+ } catch (err) {
245
+ lastErr = err;
246
+ if (attempt < this.maxRetries) {
247
+ await sleep(wait);
248
+ wait = Math.min(wait * 2, 5e3);
249
+ continue;
250
+ }
251
+ }
252
+ }
253
+ throw lastErr instanceof Error ? lastErr : new Error(`Request to ${url} failed`);
254
+ }
255
+ /** Raw C1 batch call. Items may come back as VerdictEnvelope or PendingResponse. */
256
+ async getVerdicts(items) {
257
+ const body = { items };
258
+ const res = await this.fetchWithRetry(`${this.baseUrl}/v1/verdict`, {
259
+ method: "POST",
260
+ headers: this.headers(),
261
+ body: JSON.stringify(body)
262
+ });
263
+ if (!res.ok) {
264
+ throw new Error(`Verdict API POST /v1/verdict failed: ${res.status} ${res.statusText}`);
265
+ }
266
+ return await res.json();
267
+ }
268
+ /**
269
+ * Poll a PENDING job to resolution. Returns the resolved item (may still be
270
+ * PENDING if it never resolves within maxPollAttempts). 404 is treated as
271
+ * "not ready yet" and retried; a non-ok, non-404 response throws.
272
+ */
273
+ async pollVerdict(pending) {
274
+ let attempt = 0;
275
+ let waitMs = Math.max(250, pending.eta_ms || 1e3);
276
+ let last = pending;
277
+ const url = `${this.baseUrl}/v1/verdict/${encodeURIComponent(pending.job_id)}`;
278
+ while (attempt < this.maxPollAttempts) {
279
+ await sleep(Math.min(waitMs, 5e3));
280
+ const res = await this.fetchWithRetry(url, {
281
+ method: "GET",
282
+ headers: this.headers()
283
+ });
284
+ if (res.status === 404) {
285
+ attempt += 1;
286
+ waitMs = Math.min(waitMs * 2, 5e3);
287
+ continue;
288
+ }
289
+ if (!res.ok) {
290
+ throw new Error(`Verdict API GET /v1/verdict/${pending.job_id} failed: ${res.status} ${res.statusText}`);
291
+ }
292
+ last = await res.json();
293
+ if (!isPending(last))
294
+ return last;
295
+ attempt += 1;
296
+ waitMs = Math.min(waitMs * 2, 5e3);
297
+ }
298
+ return last;
299
+ }
300
+ /**
301
+ * Resolve a single item to a final envelope, polling through PENDING. Returns
302
+ * the envelope, or the unresolved PendingResponse if it never landed.
303
+ */
304
+ async resolveVerdict(item) {
305
+ const { results } = await this.getVerdicts([item]);
306
+ const first = results[0];
307
+ if (!first)
308
+ throw new Error("Verdict API returned no results for the request.");
309
+ if (isPending(first))
310
+ return this.pollVerdict(first);
311
+ return first;
312
+ }
313
+ /** Resolve a batch, polling each PENDING to resolution. Order matches input. */
314
+ async resolveVerdicts(items) {
315
+ const { results } = await this.getVerdicts(items);
316
+ return Promise.all(results.map((r) => isPending(r) ? this.pollVerdict(r) : Promise.resolve(r)));
317
+ }
318
+ /**
319
+ * High-level fail-open resolution (the CLI scan path). Returns one
320
+ * ResolvedItem per requested item. On a total API failure (network/timeout),
321
+ * falls back to the fail-open cache for every item; for individual
322
+ * missing/never-resolved items, falls back to the cache too. Items the cache
323
+ * cannot safely satisfy surface as 'unresolved' (UNKNOWN) so the operator
324
+ * decides — never a silent allow.
325
+ */
326
+ async resolve(items) {
327
+ if (items.length === 0)
328
+ return { resolved: [], degraded: false };
329
+ let response;
330
+ try {
331
+ response = await this.getVerdicts(items);
332
+ } catch {
333
+ return this.failOpen(items);
334
+ }
335
+ const results = response.results ?? [];
336
+ const resolved = [];
337
+ for (let i = 0; i < items.length; i++) {
338
+ const item = items[i];
339
+ const r = results[i];
340
+ if (!r) {
341
+ resolved.push(this.fromCacheOrUnresolved(item));
342
+ continue;
343
+ }
344
+ if (isPending(r)) {
345
+ let env;
346
+ try {
347
+ env = await this.pollVerdict(r);
348
+ } catch {
349
+ env = void 0;
350
+ }
351
+ if (env && isEnvelope(env)) {
352
+ this.cache?.put(item, env);
353
+ resolved.push({ item, envelope: env, source: "api" });
354
+ } else {
355
+ resolved.push(this.fromCacheOrUnresolved(item));
356
+ }
357
+ continue;
358
+ }
359
+ this.cache?.put(item, r);
360
+ resolved.push({ item, envelope: r, source: "api" });
361
+ }
362
+ return { resolved, degraded: false };
363
+ }
364
+ failOpen(items) {
365
+ const resolved = items.map((item) => {
366
+ const cached = this.cache?.get(item);
367
+ if (cached) {
368
+ return { item, envelope: cached, source: "cache", failedOpen: true };
369
+ }
370
+ return { item, source: "unresolved", failedOpen: true };
371
+ });
372
+ return { resolved, degraded: true };
373
+ }
374
+ fromCacheOrUnresolved(item) {
375
+ const cached = this.cache?.get(item);
376
+ if (cached)
377
+ return { item, envelope: cached, source: "cache" };
378
+ return { item, source: "unresolved" };
379
+ }
380
+ };
381
+
382
+ // src/handlers.ts
383
+ import { randomUUID } from "crypto";
384
+
385
+ // src/alternatives.ts
386
+ var CURATED = {
387
+ // npm typosquats / known-bad fixtures -> the legitimate package.
388
+ "npm:reaact": { suggestion: "react", rationale: 'Likely typosquat of the official "react" package.' },
389
+ "npm:event-stream": {
390
+ suggestion: "readable-stream",
391
+ rationale: 'event-stream@3.3.6 carried the flatmap-stream supply-chain payload; "readable-stream" is the maintained, widely-used stream toolkit.'
392
+ },
393
+ "npm:left-pad-2": {
394
+ suggestion: "just-pad-left",
395
+ rationale: "Use a maintained, single-purpose padding helper instead of an unknown clone."
396
+ },
397
+ "npm:aws-helper-cli": {
398
+ suggestion: "@aws-sdk/client-sts",
399
+ rationale: "Use the official AWS SDK rather than a third-party credential-touching helper."
400
+ },
401
+ "npm:discord-bot-helper": {
402
+ suggestion: "discord.js",
403
+ rationale: "discord.js is the maintained, audited Discord client library."
404
+ },
405
+ "npm:fastly-cdn-uploader": {
406
+ suggestion: "@fastly/js-compute",
407
+ rationale: "Prefer Fastly's official tooling over an unaffiliated uploader."
408
+ },
409
+ "npm:analytics-tracker-pro": {
410
+ suggestion: "@vercel/analytics",
411
+ rationale: "Use a reputable analytics library instead of an unknown tracker."
412
+ },
413
+ // pypi typosquats / known-bad fixtures.
414
+ "pypi:requrest": {
415
+ suggestion: "requests",
416
+ rationale: 'Likely typosquat of the official "requests" HTTP library.'
417
+ },
418
+ "pypi:colorama-utils": {
419
+ suggestion: "colorama",
420
+ rationale: 'Use the official "colorama" package; the "-utils" variant performs persistence writes.'
421
+ }
422
+ };
423
+ function suggestAlternative(ecosystem, name) {
424
+ const key = `${ecosystem}:${name.toLowerCase()}`;
425
+ const hit = CURATED[key];
426
+ if (hit) {
427
+ return {
428
+ ecosystem,
429
+ requested: name,
430
+ suggestion: hit.suggestion,
431
+ rationale: hit.rationale,
432
+ source: "curated"
433
+ };
434
+ }
435
+ return {
436
+ ecosystem,
437
+ requested: name,
438
+ suggestion: null,
439
+ rationale: "No curated safe alternative is known for this package. (suggest_alternative currently uses a small curated map; a real recommendation backend is future work.)",
440
+ source: "none"
441
+ };
442
+ }
443
+
444
+ // src/parse-deps.ts
445
+ function cleanNpmVersion(spec) {
446
+ const trimmed = spec.trim();
447
+ if (!trimmed) return null;
448
+ if (trimmed === "*" || trimmed === "latest" || trimmed === "x" || trimmed === "X") return null;
449
+ if (/^(workspace:|link:|file:|git\+|git:|github:|https?:)/.test(trimmed)) return null;
450
+ const m = trimmed.match(/\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?/);
451
+ if (m) return m[0];
452
+ const stripped = trimmed.replace(/^[\^~>=<\s]+/, "").trim();
453
+ return /^\d/.test(stripped) ? stripped : null;
454
+ }
455
+ function parsePackageJson(content) {
456
+ const json = JSON.parse(content);
457
+ const sections = ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"];
458
+ const items = [];
459
+ const warnings = [];
460
+ const seen = /* @__PURE__ */ new Set();
461
+ for (const section of sections) {
462
+ const deps = json[section];
463
+ if (deps && typeof deps === "object") {
464
+ for (const [name, rawSpec] of Object.entries(deps)) {
465
+ if (seen.has(name)) continue;
466
+ seen.add(name);
467
+ const spec = String(rawSpec);
468
+ const alias = spec.match(/^npm:(@?[^@]+(?:\/[^@]+)?)@(.+)$/);
469
+ if (alias) {
470
+ const aliasVersion = cleanNpmVersion(alias[2]);
471
+ if (aliasVersion) {
472
+ items.push({ ecosystem: "npm", name: alias[1], version: aliasVersion });
473
+ } else {
474
+ warnings.push(`Skipped "${name}" (${spec}) \u2014 alias has no concrete version.`);
475
+ }
476
+ continue;
477
+ }
478
+ const version = cleanNpmVersion(spec);
479
+ if (version) {
480
+ items.push({ ecosystem: "npm", name, version });
481
+ } else {
482
+ warnings.push(`Skipped "${name}" (${spec}) \u2014 no concrete version (wildcard/tag/git/url).`);
483
+ }
484
+ }
485
+ }
486
+ }
487
+ return { items, warnings, skipped: warnings.length };
488
+ }
489
+ function parsePackageLock(content) {
490
+ const json = JSON.parse(content);
491
+ const items = [];
492
+ const warnings = [];
493
+ const seen = /* @__PURE__ */ new Set();
494
+ for (const [path, meta] of Object.entries(json.packages ?? {})) {
495
+ if (!path || !path.startsWith("node_modules/")) continue;
496
+ const name = path.slice(path.lastIndexOf("node_modules/") + "node_modules/".length);
497
+ if (!name || seen.has(name)) continue;
498
+ seen.add(name);
499
+ if (!meta?.version) {
500
+ warnings.push(`Skipped "${name}" \u2014 no resolved version in lockfile.`);
501
+ continue;
502
+ }
503
+ items.push({ ecosystem: "npm", name, version: meta.version });
504
+ }
505
+ return { items, warnings, skipped: warnings.length };
506
+ }
507
+ function parseRequirementsTxt(content) {
508
+ const items = [];
509
+ const warnings = [];
510
+ const seen = /* @__PURE__ */ new Set();
511
+ for (const rawLine of content.split("\n")) {
512
+ const line = rawLine.split("#")[0].trim();
513
+ if (!line) continue;
514
+ if (line.startsWith("-") || line.includes("://")) {
515
+ warnings.push(`Skipped "${line.slice(0, 60)}" \u2014 not a simple pin.`);
516
+ continue;
517
+ }
518
+ const m = line.match(/^([A-Za-z0-9._-]+)\s*(?:\[[^\]]*\])?\s*(?:[=<>!~]=?\s*([0-9][\w.+!-]*))?/);
519
+ if (!m) {
520
+ warnings.push(`Skipped "${line.slice(0, 60)}" \u2014 unparseable requirement.`);
521
+ continue;
522
+ }
523
+ const name = m[1];
524
+ const version = m[2];
525
+ if (!version) {
526
+ warnings.push(`Skipped "${line.slice(0, 60)}" \u2014 only pinned (pkg==version) deps are resolved.`);
527
+ continue;
528
+ }
529
+ if (seen.has(name)) continue;
530
+ seen.add(name);
531
+ items.push({ ecosystem: "pypi", name, version });
532
+ }
533
+ return { items, warnings, skipped: warnings.length };
534
+ }
535
+ function inferFormat(content) {
536
+ const trimmed = content.trim();
537
+ if (trimmed.startsWith("{")) {
538
+ try {
539
+ const json = JSON.parse(trimmed);
540
+ if (json.lockfileVersion !== void 0 || json.packages !== void 0) {
541
+ return "package-lock.json";
542
+ }
543
+ return "package.json";
544
+ } catch {
545
+ }
546
+ }
547
+ return "requirements.txt";
548
+ }
549
+ function parseDependenciesWithWarnings(input) {
550
+ if (input.packages && input.packages.length > 0) {
551
+ return { items: input.packages, warnings: [], skipped: 0 };
552
+ }
553
+ if (!input.content || input.content.trim() === "") {
554
+ throw new Error("check_dependencies needs either `packages` or non-empty `content`.");
555
+ }
556
+ const format = input.format ?? inferFormat(input.content);
557
+ switch (format) {
558
+ case "package.json":
559
+ return parsePackageJson(input.content);
560
+ case "package-lock.json":
561
+ return parsePackageLock(input.content);
562
+ case "requirements.txt":
563
+ return parseRequirementsTxt(input.content);
564
+ default:
565
+ throw new Error(`Unsupported manifest format: ${String(format)}`);
566
+ }
567
+ }
568
+
569
+ // src/handlers.ts
570
+ function isBlocked(action) {
571
+ return action === "BLOCK" || action === "REVIEW";
572
+ }
573
+ function stripControlChars(s) {
574
+ return s.replace(/[\u0000-\u001F\u007F-\u009F]/g, " ").replace(/\s+/g, " ").trim();
575
+ }
576
+ function fence(raw, max = 200) {
577
+ const s = stripControlChars(String(raw ?? "")).slice(0, max);
578
+ return `\xAB${s}\xBB`;
579
+ }
580
+ function fenceList(items) {
581
+ return items.map((c) => fence(c, 60)).join(", ");
582
+ }
583
+ function envelopeToVerdict(item, resolved, withAlternative) {
584
+ if (isPending(resolved)) {
585
+ return {
586
+ ecosystem: item.ecosystem,
587
+ name: item.name,
588
+ version: item.version,
589
+ pending: true,
590
+ action: "PENDING",
591
+ summary: `${fence(item.name)}@${fence(item.version, 60)}: verdict PENDING (job ${resolved.job_id}); not resolved yet \u2014 treat as unknown and re-check before install.`
592
+ };
593
+ }
594
+ const env = resolved;
595
+ const blocked = isBlocked(env.action);
596
+ const out = {
597
+ ecosystem: env.ecosystem,
598
+ name: env.name,
599
+ version: env.version,
600
+ pending: false,
601
+ status: env.status,
602
+ action: env.action,
603
+ confidence: env.confidence,
604
+ behaviors: env.behaviors,
605
+ policy_decision: env.policy_decision,
606
+ provisional: env.provisional,
607
+ summary: `${fence(env.name)}@${fence(env.version, 60)}: ${env.status.toUpperCase()} -> ${env.action}${env.behaviors.length ? ` (${fenceList(env.behaviors.map((b) => b.category))})` : ""}${env.provisional ? " [provisional]" : ""}`
608
+ };
609
+ if (withAlternative && blocked) {
610
+ out.suggested_alternative = suggestAlternative(env.ecosystem, env.name);
611
+ }
612
+ return out;
613
+ }
614
+ async function checkPackage(client, args) {
615
+ const item = {
616
+ ecosystem: args.ecosystem,
617
+ name: args.name,
618
+ version: args.version
619
+ };
620
+ const resolved = await client.resolveVerdict(item);
621
+ return envelopeToVerdict(item, resolved, true);
622
+ }
623
+ async function checkDependencies(client, args) {
624
+ const { items, warnings, skipped } = parseDependenciesWithWarnings(args);
625
+ if (items.length === 0) {
626
+ return {
627
+ tally: { total: 0, allow: 0, review: 0, block: 0, pending: 0 },
628
+ verdicts: [],
629
+ has_risk: false,
630
+ skipped,
631
+ warnings
632
+ };
633
+ }
634
+ const resolved = await client.resolveVerdicts(items);
635
+ const verdicts = items.map((item, i) => envelopeToVerdict(item, resolved[i], true));
636
+ const tally = {
637
+ total: verdicts.length,
638
+ allow: verdicts.filter((v2) => v2.action === "ALLOW").length,
639
+ review: verdicts.filter((v2) => v2.action === "REVIEW").length,
640
+ block: verdicts.filter((v2) => v2.action === "BLOCK").length,
641
+ pending: verdicts.filter((v2) => v2.action === "PENDING").length
642
+ };
643
+ const has_risk = tally.block + tally.review + tally.pending + skipped > 0;
644
+ return { tally, verdicts, has_risk, skipped, warnings };
645
+ }
646
+ async function explainVerdict(client, args) {
647
+ const item = {
648
+ ecosystem: args.ecosystem,
649
+ name: args.name,
650
+ version: args.version
651
+ };
652
+ const resolved = await client.resolveVerdict(item);
653
+ if (isPending(resolved)) {
654
+ return {
655
+ ecosystem: args.ecosystem,
656
+ name: args.name,
657
+ version: args.version,
658
+ pending: true,
659
+ explanation: `No verdict is available yet for ${fence(args.name)}@${fence(args.version, 60)} (still PENDING, job ${resolved.job_id}). The package is being analyzed; re-check shortly.`
660
+ };
661
+ }
662
+ const env = resolved;
663
+ const behaviorLines = env.behaviors.map(
664
+ (b) => `- ${fence(b.category, 60)} (${fence(b.severity, 40)}) [evidence: ${fence(b.evidence_ref, 120)}]`
665
+ );
666
+ const explanation = `${fence(env.name)}@${fence(env.version, 60)} is ${env.status.toUpperCase()} -> action ${env.action} (confidence ${(env.confidence * 100).toFixed(0)}%, ${env.corroboration}${env.provisional ? ", provisional" : ""}).
667
+ ` + (behaviorLines.length ? `Observed behaviors:
668
+ ${behaviorLines.join("\n")}` : "No specific behaviors were flagged.") + (env.reasoning ? `
669
+ Engine reasoning: ${fence(env.reasoning, 500)}` : "") + `
670
+ Evidence trace: ${fence(env.evidence_uri, 200)}`;
671
+ return {
672
+ ecosystem: env.ecosystem,
673
+ name: env.name,
674
+ version: env.version,
675
+ pending: false,
676
+ status: env.status,
677
+ action: env.action,
678
+ confidence: env.confidence,
679
+ corroboration: env.corroboration,
680
+ assurance: env.assurance,
681
+ provisional: env.provisional,
682
+ behaviors: env.behaviors.map((b) => ({
683
+ category: b.category,
684
+ severity: b.severity,
685
+ evidence_ref: b.evidence_ref
686
+ })),
687
+ capabilities: env.capabilities,
688
+ reasoning: env.reasoning,
689
+ evidence_uri: env.evidence_uri,
690
+ explanation
691
+ };
692
+ }
693
+ function suggestAlternativeHandler(args) {
694
+ return suggestAlternative(args.ecosystem, args.name);
695
+ }
696
+ var ReviewStore = class {
697
+ reviews = [];
698
+ record(args) {
699
+ const review = {
700
+ // crypto.randomUUID() avoids the ms-granularity collisions of Date.now()
701
+ // when two reviews for the same coordinate are recorded in the same tick.
702
+ id: `review-${args.ecosystem}-${args.name}${args.version ? `@${args.version}` : ""}-${randomUUID()}`,
703
+ ecosystem: args.ecosystem,
704
+ name: args.name,
705
+ version: args.version,
706
+ reason: args.reason,
707
+ requested_at: (/* @__PURE__ */ new Date()).toISOString()
708
+ };
709
+ this.reviews.push(review);
710
+ return review;
711
+ }
712
+ list() {
713
+ return [...this.reviews];
714
+ }
715
+ };
716
+
717
+ // src/server.ts
718
+ var ecosystemSchema = z.enum(ECOSYSTEM);
719
+ function ok(structured, text) {
720
+ return {
721
+ content: [{ type: "text", text }],
722
+ structuredContent: structured
723
+ };
724
+ }
725
+ function fail(err) {
726
+ const message = err instanceof Error ? err.message : String(err);
727
+ return {
728
+ content: [{ type: "text", text: `Error: ${message}` }],
729
+ isError: true
730
+ };
731
+ }
732
+ function buildServer(clientOpts = {}) {
733
+ const client = new VerdictClient(clientOpts);
734
+ const reviews = new ReviewStore();
735
+ const server = new McpServer(
736
+ { name: "inner-mcp", version: "0.1.0" },
737
+ {
738
+ instructions: "Inner security gate for package installs. Before you run npm/pip install, call check_package (or check_dependencies for a whole manifest). If a package is BLOCK or REVIEW, do NOT install it \u2014 use suggest_alternative for a safer option, or request_review to flag it. Use explain_verdict to show the user why."
739
+ }
740
+ );
741
+ server.registerTool(
742
+ "check_package",
743
+ {
744
+ title: "Check a package before installing",
745
+ description: "Get the Inner verdict (status/action/confidence/behaviors) for a single package, plus a suggested safer alternative when it is blocked. Call this BEFORE installing.",
746
+ inputSchema: {
747
+ ecosystem: ecosystemSchema.describe("Package ecosystem: npm or pypi"),
748
+ name: z.string().describe('Package name, e.g. "react"'),
749
+ version: z.string().describe('Exact version, e.g. "18.3.1"')
750
+ }
751
+ },
752
+ async (args) => {
753
+ try {
754
+ const verdict = await checkPackage(client, args);
755
+ return ok(verdict, verdict.summary);
756
+ } catch (err) {
757
+ return fail(err);
758
+ }
759
+ }
760
+ );
761
+ server.registerTool(
762
+ "check_dependencies",
763
+ {
764
+ title: "Check a whole dependency set",
765
+ description: "Resolve verdicts for an entire manifest/lockfile (package.json, package-lock.json, requirements.txt) or an explicit package list. Returns per-package verdicts + a tally.",
766
+ inputSchema: {
767
+ content: z.string().optional().describe("Raw manifest/lockfile contents (package.json, package-lock.json, or requirements.txt)."),
768
+ format: z.enum(["package.json", "package-lock.json", "requirements.txt"]).optional().describe("Parser to use; inferred from content when omitted."),
769
+ ecosystem: ecosystemSchema.optional().describe("Ecosystem hint for ambiguous inputs."),
770
+ packages: z.array(
771
+ z.object({
772
+ ecosystem: ecosystemSchema,
773
+ name: z.string(),
774
+ version: z.string()
775
+ })
776
+ ).optional().describe("Explicit package list (bypasses parsing).")
777
+ }
778
+ },
779
+ async (args) => {
780
+ try {
781
+ const result = await checkDependencies(client, args);
782
+ const { tally } = result;
783
+ const text = `Checked ${tally.total} package(s): ${tally.allow} ALLOW, ${tally.review} REVIEW, ${tally.block} BLOCK, ${tally.pending} PENDING.` + (result.skipped > 0 ? ` ${result.skipped} entr${result.skipped === 1 ? "y" : "ies"} skipped as unparseable (NOT scanned \u2014 treat as unprotected).` : "") + (result.has_risk ? " Risk present \u2014 review BLOCK/REVIEW/PENDING/skipped before installing." : " All clear.");
784
+ return ok(result, text);
785
+ } catch (err) {
786
+ return fail(err);
787
+ }
788
+ }
789
+ );
790
+ server.registerTool(
791
+ "explain_verdict",
792
+ {
793
+ title: "Explain a verdict",
794
+ description: "Deep-insight reasoning for a package: the behaviors observed, their severity, the evidence each cites, corroboration/assurance, and a narrative explanation.",
795
+ inputSchema: {
796
+ ecosystem: ecosystemSchema.describe("Package ecosystem: npm or pypi"),
797
+ name: z.string().describe("Package name"),
798
+ version: z.string().describe("Exact version")
799
+ }
800
+ },
801
+ async (args) => {
802
+ try {
803
+ const explanation = await explainVerdict(client, args);
804
+ return ok(explanation, explanation.explanation);
805
+ } catch (err) {
806
+ return fail(err);
807
+ }
808
+ }
809
+ );
810
+ server.registerTool(
811
+ "suggest_alternative",
812
+ {
813
+ title: "Suggest a safer alternative",
814
+ description: "For a blocked/risky package, suggest a safer equivalent. Heuristic/curated for now (a real recommendation backend is future work); returns null when nothing is known.",
815
+ inputSchema: {
816
+ ecosystem: ecosystemSchema.describe("Package ecosystem: npm or pypi"),
817
+ name: z.string().describe("The package to find an alternative for")
818
+ }
819
+ },
820
+ async (args) => {
821
+ try {
822
+ const suggestion = suggestAlternativeHandler(args);
823
+ const text = suggestion.suggestion ? `Suggested alternative for ${suggestion.requested}: ${suggestion.suggestion} \u2014 ${suggestion.rationale}` : suggestion.rationale;
824
+ return ok(suggestion, text);
825
+ } catch (err) {
826
+ return fail(err);
827
+ }
828
+ }
829
+ );
830
+ server.registerTool(
831
+ "request_review",
832
+ {
833
+ title: "Request a human review",
834
+ description: "Record a request for a human to review a package (e.g. a false positive or an unresolved verdict). Returns a review ticket id.",
835
+ inputSchema: {
836
+ ecosystem: ecosystemSchema.describe("Package ecosystem: npm or pypi"),
837
+ name: z.string().describe("Package name"),
838
+ version: z.string().optional().describe("Version, if known"),
839
+ reason: z.string().optional().describe("Why a review is requested")
840
+ }
841
+ },
842
+ async (args) => {
843
+ try {
844
+ const review = reviews.record(args);
845
+ return ok(
846
+ review,
847
+ `Review requested for ${review.name}${review.version ? `@${review.version}` : ""} (ticket ${review.id}).`
848
+ );
849
+ } catch (err) {
850
+ return fail(err);
851
+ }
852
+ }
853
+ );
854
+ return server;
855
+ }
856
+ async function main() {
857
+ const server = buildServer();
858
+ const transport = new StdioServerTransport();
859
+ await server.connect(transport);
860
+ const apiUrl = process.env.INNER_API_URL ?? `${DEFAULT_API_URL} (local mock)`;
861
+ process.stderr.write(`[inner-mcp] ready on stdio; Verdict API: ${apiUrl}
862
+ `);
863
+ }
864
+ var invokedDirectly = (() => {
865
+ const entry = process.argv[1];
866
+ if (entry === void 0) return false;
867
+ try {
868
+ const real = realpathSync(entry);
869
+ return import.meta.url === pathToFileURL(real).href;
870
+ } catch {
871
+ return import.meta.url === pathToFileURL(entry).href;
872
+ }
873
+ })();
874
+ if (invokedDirectly) {
875
+ main().catch((err) => {
876
+ process.stderr.write(`[inner-mcp] fatal: ${err instanceof Error ? err.stack : String(err)}
877
+ `);
878
+ process.exit(1);
879
+ });
880
+ }
881
+ export {
882
+ buildServer
883
+ };