pr-checkmate 3.3.0 → 4.0.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.
Files changed (127) hide show
  1. package/LICENSE +8 -3
  2. package/README.md +59 -4
  3. package/cspell.json +1 -0
  4. package/dist/cjs/agent/hook.d.ts +38 -0
  5. package/dist/cjs/agent/hook.js +175 -0
  6. package/dist/cjs/agent/registration.d.ts +10 -0
  7. package/dist/cjs/agent/registration.js +28 -0
  8. package/dist/cjs/agent/setup.d.ts +21 -0
  9. package/dist/cjs/agent/setup.js +229 -0
  10. package/dist/cjs/cli.js +88 -0
  11. package/dist/cjs/config/constants.d.ts +2 -0
  12. package/dist/cjs/config/constants.js +5 -1
  13. package/dist/cjs/config/pr-checkmate.template.json +10 -0
  14. package/dist/cjs/core/checks/findings.js +33 -7
  15. package/dist/cjs/core/checks/git/diff-smell.js +3 -1
  16. package/dist/cjs/core/checks/git/large-files.d.ts +1 -0
  17. package/dist/cjs/core/checks/git/large-files.js +3 -3
  18. package/dist/cjs/core/checks/languages/lint.js +27 -0
  19. package/dist/cjs/core/checks/languages/locate.d.ts +7 -0
  20. package/dist/cjs/core/checks/languages/locate.js +68 -0
  21. package/dist/cjs/core/checks/languages/prettier.js +15 -2
  22. package/dist/cjs/core/checks/languages/typecheck.js +4 -1
  23. package/dist/cjs/core/checks/pr/pr-size-check.d.ts +2 -0
  24. package/dist/cjs/core/checks/pr/pr-size-check.js +5 -5
  25. package/dist/cjs/core/checks/security/agent-instructions.d.ts +4 -0
  26. package/dist/cjs/core/checks/security/agent-instructions.js +149 -0
  27. package/dist/cjs/core/checks/security/diff-security.d.ts +7 -0
  28. package/dist/cjs/core/checks/security/diff-security.js +21 -1
  29. package/dist/cjs/core/checks/security/security.d.ts +0 -13
  30. package/dist/cjs/core/checks/security/security.js +27 -0
  31. package/dist/cjs/core/checks/security/workflow-security.js +33 -0
  32. package/dist/cjs/core/commands.d.ts +6 -0
  33. package/dist/cjs/core/commands.js +11 -1
  34. package/dist/cjs/core/finding-store.d.ts +16 -0
  35. package/dist/cjs/core/finding-store.js +73 -0
  36. package/dist/cjs/core/registry.js +2 -0
  37. package/dist/cjs/core/reporters.js +9 -0
  38. package/dist/cjs/core/run-checks.js +15 -5
  39. package/dist/cjs/core/sarif.d.ts +15 -0
  40. package/dist/cjs/core/sarif.js +60 -0
  41. package/dist/cjs/core/types.d.ts +7 -2
  42. package/dist/cjs/index.d.ts +2 -1
  43. package/dist/cjs/index.js +4 -1
  44. package/dist/cjs/init.js +11 -0
  45. package/dist/cjs/interfaces/index.d.ts +12 -0
  46. package/dist/cjs/mcp/config-tools.d.ts +30 -0
  47. package/dist/cjs/mcp/config-tools.js +320 -0
  48. package/dist/cjs/mcp/dependency.d.ts +34 -0
  49. package/dist/cjs/mcp/dependency.js +413 -0
  50. package/dist/cjs/mcp/protocol.d.ts +91 -0
  51. package/dist/cjs/mcp/protocol.js +181 -0
  52. package/dist/cjs/mcp/review.d.ts +64 -0
  53. package/dist/cjs/mcp/review.js +150 -0
  54. package/dist/cjs/mcp/serve.d.ts +8 -0
  55. package/dist/cjs/mcp/serve.js +49 -0
  56. package/dist/cjs/mcp/settings.d.ts +13 -0
  57. package/dist/cjs/mcp/settings.js +50 -0
  58. package/dist/cjs/mcp/tools.d.ts +23 -0
  59. package/dist/cjs/mcp/tools.js +618 -0
  60. package/dist/cjs/utils/execa.d.ts +7 -0
  61. package/dist/cjs/utils/execa.js +41 -1
  62. package/dist/cjs/utils/result-collector.d.ts +17 -1
  63. package/dist/cjs/utils/result-collector.js +7 -2
  64. package/dist/esm/agent/hook.d.ts +38 -0
  65. package/dist/esm/agent/hook.js +167 -0
  66. package/dist/esm/agent/registration.d.ts +10 -0
  67. package/dist/esm/agent/registration.js +21 -0
  68. package/dist/esm/agent/setup.d.ts +21 -0
  69. package/dist/esm/agent/setup.js +220 -0
  70. package/dist/esm/cli.js +86 -1
  71. package/dist/esm/config/constants.d.ts +2 -0
  72. package/dist/esm/config/constants.js +4 -0
  73. package/dist/esm/config/pr-checkmate.template.json +10 -0
  74. package/dist/esm/core/checks/findings.js +33 -7
  75. package/dist/esm/core/checks/git/diff-smell.js +3 -1
  76. package/dist/esm/core/checks/git/large-files.d.ts +1 -0
  77. package/dist/esm/core/checks/git/large-files.js +1 -1
  78. package/dist/esm/core/checks/languages/lint.js +28 -1
  79. package/dist/esm/core/checks/languages/locate.d.ts +7 -0
  80. package/dist/esm/core/checks/languages/locate.js +60 -0
  81. package/dist/esm/core/checks/languages/prettier.js +15 -2
  82. package/dist/esm/core/checks/languages/typecheck.js +4 -1
  83. package/dist/esm/core/checks/pr/pr-size-check.d.ts +2 -0
  84. package/dist/esm/core/checks/pr/pr-size-check.js +2 -2
  85. package/dist/esm/core/checks/security/agent-instructions.d.ts +4 -0
  86. package/dist/esm/core/checks/security/agent-instructions.js +146 -0
  87. package/dist/esm/core/checks/security/diff-security.d.ts +7 -0
  88. package/dist/esm/core/checks/security/diff-security.js +20 -0
  89. package/dist/esm/core/checks/security/security.d.ts +0 -13
  90. package/dist/esm/core/checks/security/security.js +27 -0
  91. package/dist/esm/core/checks/security/workflow-security.js +33 -0
  92. package/dist/esm/core/commands.d.ts +6 -0
  93. package/dist/esm/core/commands.js +10 -0
  94. package/dist/esm/core/finding-store.d.ts +16 -0
  95. package/dist/esm/core/finding-store.js +67 -0
  96. package/dist/esm/core/registry.js +2 -0
  97. package/dist/esm/core/reporters.js +9 -0
  98. package/dist/esm/core/run-checks.js +15 -5
  99. package/dist/esm/core/sarif.d.ts +15 -0
  100. package/dist/esm/core/sarif.js +57 -0
  101. package/dist/esm/core/types.d.ts +7 -2
  102. package/dist/esm/index.d.ts +2 -1
  103. package/dist/esm/index.js +2 -0
  104. package/dist/esm/init.js +12 -1
  105. package/dist/esm/interfaces/index.d.ts +12 -0
  106. package/dist/esm/mcp/config-tools.d.ts +30 -0
  107. package/dist/esm/mcp/config-tools.js +310 -0
  108. package/dist/esm/mcp/dependency.d.ts +34 -0
  109. package/dist/esm/mcp/dependency.js +408 -0
  110. package/dist/esm/mcp/protocol.d.ts +91 -0
  111. package/dist/esm/mcp/protocol.js +175 -0
  112. package/dist/esm/mcp/review.d.ts +64 -0
  113. package/dist/esm/mcp/review.js +139 -0
  114. package/dist/esm/mcp/serve.d.ts +8 -0
  115. package/dist/esm/mcp/serve.js +43 -0
  116. package/dist/esm/mcp/settings.d.ts +13 -0
  117. package/dist/esm/mcp/settings.js +43 -0
  118. package/dist/esm/mcp/tools.d.ts +23 -0
  119. package/dist/esm/mcp/tools.js +611 -0
  120. package/dist/esm/utils/execa.d.ts +7 -0
  121. package/dist/esm/utils/execa.js +40 -1
  122. package/dist/esm/utils/result-collector.d.ts +17 -1
  123. package/dist/esm/utils/result-collector.js +7 -2
  124. package/package.json +7 -3
  125. package/pr-checkmate.schema.json +35 -0
  126. package/src/config/cspell.json +1 -0
  127. package/src/config/pr-checkmate.schema.json +35 -0
@@ -0,0 +1,413 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.lookalikeOf = lookalikeOf;
4
+ exports.checkDependency = checkDependency;
5
+ exports.formatDependency = formatDependency;
6
+ // cspell:ignore beautifulsoup boto colorama lodahs lxml psycopg pyyaml scikit setuptools slopsquatting sqlalchemy tqdm
7
+ /**
8
+ * Should this package be added at all? — answered at the moment an agent reaches
9
+ * for it, not weeks later in an audit.
10
+ *
11
+ * Language models invent package names that sound right, and attackers publish
12
+ * those names (slopsquatting). They also reach for whatever version they remember,
13
+ * which is often one with a known advisory. Both mistakes are cheap to catch with
14
+ * two public lookups — the registry and OSV — and expensive to catch after the
15
+ * package is in a lockfile and installed on a developer's machine.
16
+ *
17
+ * What is sent: the package name, and the version if one was asked about. Nothing
18
+ * from the repository leaves the machine.
19
+ *
20
+ * The honest-failure rule: if a lookup cannot be completed the verdict is
21
+ * `unknown`, never `ok`. A check that passes because it could not look is the
22
+ * exact failure this exists to prevent.
23
+ */
24
+ const http_js_1 = require("../utils/http.js");
25
+ const live = (url, init) => (0, http_js_1.request)(url, init, { timeoutMs: 8000, retries: 2, label: 'dependency' });
26
+ const NPM_NAME = /^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/;
27
+ const PYPI_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
28
+ const VERSION = /^[0-9A-Za-z][0-9A-Za-z.+_-]*$/;
29
+ /** Newer than this, and nobody has had time to notice if it is malicious. */
30
+ const NEW_PACKAGE_DAYS = 30;
31
+ /** Fewer weekly downloads than this and the package is effectively unreviewed. */
32
+ const LOW_DOWNLOADS = 100;
33
+ /** No release for this long: not a vulnerability, but nobody is fixing one if it appears. */
34
+ const STALE_DAYS = 365 * 2;
35
+ const DAY_MS = 86400000;
36
+ /** Names an attacker would imitate. Deliberately short: the point is the near miss, not coverage. */
37
+ const POPULAR = {
38
+ npm: [
39
+ 'react',
40
+ 'react-dom',
41
+ 'lodash',
42
+ 'express',
43
+ 'axios',
44
+ 'moment',
45
+ 'chalk',
46
+ 'commander',
47
+ 'debug',
48
+ 'typescript',
49
+ 'webpack',
50
+ 'eslint',
51
+ 'prettier',
52
+ 'jest',
53
+ 'mocha',
54
+ 'vue',
55
+ 'next',
56
+ 'jquery',
57
+ 'underscore',
58
+ 'request',
59
+ 'async',
60
+ 'bluebird',
61
+ 'uuid',
62
+ 'dotenv',
63
+ 'cors',
64
+ 'body-parser',
65
+ 'mongoose',
66
+ 'mysql',
67
+ 'redis',
68
+ 'socket.io',
69
+ 'rxjs',
70
+ 'zod',
71
+ 'yargs',
72
+ 'glob',
73
+ 'rimraf',
74
+ 'minimist',
75
+ 'semver',
76
+ 'colors',
77
+ 'tslib',
78
+ 'vite',
79
+ 'babel-core',
80
+ 'cross-env',
81
+ 'nodemon',
82
+ 'passport',
83
+ 'jsonwebtoken',
84
+ 'bcrypt',
85
+ 'puppeteer',
86
+ 'playwright',
87
+ 'cheerio',
88
+ 'ws',
89
+ ],
90
+ pypi: [
91
+ 'requests',
92
+ 'numpy',
93
+ 'pandas',
94
+ 'django',
95
+ 'flask',
96
+ 'urllib3',
97
+ 'boto3',
98
+ 'setuptools',
99
+ 'pytest',
100
+ 'scipy',
101
+ 'matplotlib',
102
+ 'pillow',
103
+ 'sqlalchemy',
104
+ 'beautifulsoup4',
105
+ 'fastapi',
106
+ 'pydantic',
107
+ 'cryptography',
108
+ 'pyyaml',
109
+ 'jinja2',
110
+ 'click',
111
+ 'tensorflow',
112
+ 'torch',
113
+ 'scikit-learn',
114
+ 'selenium',
115
+ 'httpx',
116
+ 'aiohttp',
117
+ 'lxml',
118
+ 'psycopg2',
119
+ 'redis',
120
+ 'celery',
121
+ 'openai',
122
+ 'anthropic',
123
+ 'langchain',
124
+ 'transformers',
125
+ 'colorama',
126
+ 'tqdm',
127
+ 'rich',
128
+ 'black',
129
+ 'mypy',
130
+ 'ruff',
131
+ ],
132
+ };
133
+ /**
134
+ * Edit distance where swapping two neighbours counts as one edit (`lodahs` is one
135
+ * slip from `lodash`, not two). Transpositions are the commonest typo and the
136
+ * commonest squat. Capped: all that is asked is "within a couple of keystrokes".
137
+ */
138
+ function distance(a, b) {
139
+ if (Math.abs(a.length - b.length) > 2)
140
+ return 3;
141
+ const d = Array.from({ length: a.length + 1 }, (_, i) => [i]);
142
+ for (let j = 1; j <= b.length; j++)
143
+ d[0][j] = j;
144
+ for (let i = 1; i <= a.length; i++) {
145
+ for (let j = 1; j <= b.length; j++) {
146
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
147
+ d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + cost);
148
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
149
+ d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1);
150
+ }
151
+ }
152
+ }
153
+ return d[a.length][b.length];
154
+ }
155
+ /** The famous package `name` is suspiciously close to, if any. A name that *is* the famous one is not a match. */
156
+ function lookalikeOf(name, ecosystem) {
157
+ const bare = name.replace(/^@[^/]+\//, '').toLowerCase();
158
+ const list = POPULAR[ecosystem];
159
+ if (list.includes(bare))
160
+ return undefined;
161
+ // Very short names are within two edits of everything; only compare ones long enough to mean something.
162
+ return bare.length < 4
163
+ ? undefined
164
+ : list.find(p => distance(bare, p) <= (bare.length >= 8 ? 2 : 1));
165
+ }
166
+ const isRecord = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
167
+ const daysSince = (iso) => {
168
+ const t = typeof iso === 'string' ? Date.parse(iso) : NaN;
169
+ return Number.isNaN(t) ? undefined : Math.floor((Date.now() - t) / DAY_MS);
170
+ };
171
+ /**
172
+ * Record why a registry answer cannot be used: "no such package" is fatal, an answer
173
+ * that is missing or unreadable only leaves the report incomplete. Returns `true`
174
+ * when the lookup should stop; `false` means `res.body` is a JSON object.
175
+ */
176
+ function rejectRegistryAnswer(out, res, registry) {
177
+ if (res.status === 404) {
178
+ out.fatal = true;
179
+ out.facts.exists = false;
180
+ out.reasons.push(`not on ${registry} — a name that sounds right but does not exist is how hallucinated packages get squatted; do not add it`);
181
+ return true;
182
+ }
183
+ if (!res.ok || !isRecord(res.body)) {
184
+ out.incomplete = true;
185
+ out.reasons.push(`could not read ${registry} (${res.error ?? res.status})`);
186
+ return true;
187
+ }
188
+ return false;
189
+ }
190
+ /** Fetch a registry document: a fresh `Collected` plus the JSON object, absent when it cannot be used. */
191
+ async function openRegistry(url, get, registry) {
192
+ const out = { reasons: [], facts: {}, fatal: false, incomplete: false };
193
+ const res = await get(url);
194
+ if (rejectRegistryAnswer(out, res, registry))
195
+ return { out };
196
+ return { out, doc: res.body };
197
+ }
198
+ async function lookupNpm(name, requested, get) {
199
+ const { out, doc } = await openRegistry(`https://registry.npmjs.org/${name.replace('/', '%2f')}`, get, 'the npm registry');
200
+ if (!doc)
201
+ return out;
202
+ const tags = isRecord(doc['dist-tags']) ? doc['dist-tags'] : {};
203
+ const versions = isRecord(doc.versions) ? doc.versions : {};
204
+ const time = isRecord(doc.time) ? doc.time : {};
205
+ const target = requested ?? (typeof tags.latest === 'string' ? tags.latest : undefined);
206
+ out.facts.exists = true;
207
+ if (requested && !(requested in versions)) {
208
+ out.fatal = true;
209
+ out.reasons.push(`version ${requested} was never published — the latest is ${String(tags.latest)}`);
210
+ return out;
211
+ }
212
+ out.version = target;
213
+ if (target)
214
+ out.facts.version = target;
215
+ const info = target && isRecord(versions[target]) ? versions[target] : {};
216
+ const scripts = isRecord(info.scripts) ? info.scripts : {};
217
+ if (typeof info.deprecated === 'string') {
218
+ out.reasons.push(`deprecated by its maintainer: ${info.deprecated.slice(0, 160)}`);
219
+ }
220
+ if (info.hasInstallScript === true ||
221
+ ['preinstall', 'install', 'postinstall'].some(s => s in scripts)) {
222
+ out.facts.installScript = true;
223
+ out.reasons.push('runs a script at install time — the usual route for a malicious package; read it before adding');
224
+ }
225
+ if (typeof info.license === 'string')
226
+ out.facts.license = info.license;
227
+ const age = daysSince(time.created);
228
+ if (age !== undefined) {
229
+ out.facts.ageDays = age;
230
+ if (age < NEW_PACKAGE_DAYS)
231
+ out.reasons.push(`first published ${age} day(s) ago — too new to have been reviewed by anyone`);
232
+ }
233
+ // Only the newest release can be "abandoned"; for an older pinned version the
234
+ // useful fact is that a newer one exists, which the caller already chose to ignore.
235
+ const last = daysSince(target ? time[target] : undefined);
236
+ if (target && target === tags.latest && last !== undefined && last > STALE_DAYS) {
237
+ out.reasons.push(`no release in ${Math.floor(last / 365)} years — if a flaw turns up, nobody is likely to fix it`);
238
+ }
239
+ if (requested && typeof tags.latest === 'string' && requested !== tags.latest) {
240
+ out.facts.latest = tags.latest;
241
+ }
242
+ const dl = await get(`https://api.npmjs.org/downloads/point/last-week/${name.replace('/', '%2f')}`);
243
+ if (dl.ok && isRecord(dl.body) && typeof dl.body.downloads === 'number') {
244
+ out.facts.weeklyDownloads = dl.body.downloads;
245
+ if (dl.body.downloads < LOW_DOWNLOADS) {
246
+ out.reasons.push(`only ${dl.body.downloads} downloads last week — effectively unreviewed`);
247
+ }
248
+ }
249
+ return out;
250
+ }
251
+ async function lookupPypi(name, requested, get) {
252
+ const { out, doc } = await openRegistry(`https://pypi.org/pypi/${encodeURIComponent(name)}/json`, get, 'PyPI');
253
+ if (!doc)
254
+ return out;
255
+ const info = isRecord(doc.info) ? doc.info : {};
256
+ const releases = isRecord(doc.releases) ? doc.releases : {};
257
+ out.facts.exists = true;
258
+ if (requested && !(requested in releases)) {
259
+ out.fatal = true;
260
+ out.reasons.push(`version ${requested} was never published — the latest is ${String(info.version)}`);
261
+ return out;
262
+ }
263
+ const target = requested ?? (typeof info.version === 'string' ? info.version : undefined);
264
+ out.version = target;
265
+ if (target)
266
+ out.facts.version = target;
267
+ if (typeof info.license === 'string' && info.license.length < 80)
268
+ out.facts.license = info.license;
269
+ const files = target && Array.isArray(releases[target]) ? releases[target] : [];
270
+ if (files.length > 0 && files.every(f => isRecord(f) && f.yanked === true)) {
271
+ out.reasons.push('this release was yanked by its maintainer');
272
+ }
273
+ const uploads = Object.values(releases)
274
+ .flatMap(r => (Array.isArray(r) ? r : []))
275
+ .map(f => isRecord(f) ? Date.parse(String(f.upload_time_iso_8601 ?? f.upload_time ?? '')) : NaN)
276
+ .filter(t => !Number.isNaN(t));
277
+ if (uploads.length > 0) {
278
+ const age = Math.floor((Date.now() - Math.min(...uploads)) / DAY_MS);
279
+ out.facts.ageDays = age;
280
+ if (age < NEW_PACKAGE_DAYS)
281
+ out.reasons.push(`first published ${age} day(s) ago — too new to have been reviewed by anyone`);
282
+ }
283
+ return out;
284
+ }
285
+ /** Advisories for one exact version, from OSV. Severity is taken from the advisory when it states one. */
286
+ async function lookupAdvisories(name, version, ecosystem, get) {
287
+ const res = await get('https://api.osv.dev/v1/query', {
288
+ method: 'POST',
289
+ headers: { 'content-type': 'application/json' },
290
+ body: JSON.stringify({
291
+ package: { name, ecosystem: ecosystem === 'npm' ? 'npm' : 'PyPI' },
292
+ version,
293
+ }),
294
+ });
295
+ if (!res.ok) {
296
+ return {
297
+ reasons: [`could not check advisories (${res.error ?? res.status})`],
298
+ fatal: false,
299
+ incomplete: true,
300
+ count: 0,
301
+ };
302
+ }
303
+ const vulns = isRecord(res.body) && Array.isArray(res.body.vulns) ? res.body.vulns : [];
304
+ if (vulns.length === 0)
305
+ return { reasons: [], fatal: false, incomplete: false, count: 0 };
306
+ const severities = vulns.map(v => {
307
+ const db = isRecord(v) && isRecord(v.database_specific) ? v.database_specific : {};
308
+ return typeof db.severity === 'string' ? db.severity.toUpperCase() : 'UNKNOWN';
309
+ });
310
+ const serious = severities.filter(s => s === 'CRITICAL' || s === 'HIGH').length;
311
+ // OSV files known malicious packages under `MAL-` ids. These are not "a bug with a
312
+ // fix": the package is the attack, and no version of it is safe to add.
313
+ const malware = vulns.filter(v => isRecord(v) && String(v.id).startsWith('MAL-')).length;
314
+ const ids = vulns
315
+ .slice(0, 4)
316
+ .map(v => (isRecord(v) ? String(v.id) : '?'))
317
+ .join(', ');
318
+ if (malware > 0) {
319
+ return {
320
+ reasons: [
321
+ `flagged as MALWARE in the advisory database (${ids}) — this package is the attack; do not add it`,
322
+ ],
323
+ fatal: true,
324
+ incomplete: false,
325
+ count: vulns.length,
326
+ };
327
+ }
328
+ return {
329
+ reasons: [
330
+ `${vulns.length} known advisor${vulns.length === 1 ? 'y' : 'ies'} for ${version}${serious ? ` (${serious} high or critical)` : ''}: ${ids}${vulns.length > 4 ? ', …' : ''}`,
331
+ ],
332
+ fatal: serious > 0,
333
+ incomplete: false,
334
+ count: vulns.length,
335
+ };
336
+ }
337
+ /** Is `name` safe to add? Never throws: every failure becomes part of the verdict. */
338
+ async function checkDependency(name, opts = {}) {
339
+ const ecosystem = opts.ecosystem ?? 'npm';
340
+ const get = opts.fetchJson ?? live;
341
+ const base = { name, ecosystem, ...(opts.version && { version: opts.version }) };
342
+ // Names go into URLs, so a name that is not a valid package name is refused, not escaped and hoped for.
343
+ const validName = ecosystem === 'npm' ? NPM_NAME.test(name) && name.length <= 214 : PYPI_NAME.test(name);
344
+ if (!validName) {
345
+ return {
346
+ ...base,
347
+ verdict: 'avoid',
348
+ reasons: [`"${name}" is not a valid ${ecosystem} package name`],
349
+ facts: {},
350
+ };
351
+ }
352
+ if (opts.version && !VERSION.test(opts.version)) {
353
+ return {
354
+ ...base,
355
+ verdict: 'avoid',
356
+ reasons: [`"${opts.version}" is not a valid version`],
357
+ facts: {},
358
+ };
359
+ }
360
+ const found = await (ecosystem === 'npm' ? lookupNpm : lookupPypi)(name, opts.version, get);
361
+ const reasons = [...found.reasons];
362
+ let fatal = found.fatal;
363
+ let incomplete = found.incomplete;
364
+ const twin = lookalikeOf(name, ecosystem);
365
+ if (twin)
366
+ reasons.push(`name is one edit away from the popular "${twin}" — check it is not a typo or an impersonation`);
367
+ if (!fatal && !incomplete && found.version) {
368
+ const adv = await lookupAdvisories(name, found.version, ecosystem, get);
369
+ reasons.push(...adv.reasons);
370
+ fatal || (fatal = adv.fatal);
371
+ incomplete || (incomplete = adv.incomplete);
372
+ found.facts.advisories = adv.count;
373
+ }
374
+ const verdict = fatal
375
+ ? 'avoid'
376
+ : incomplete
377
+ ? 'unknown'
378
+ : reasons.length > 0
379
+ ? 'review'
380
+ : 'ok';
381
+ return {
382
+ ...base,
383
+ ...(found.version && { version: found.version }),
384
+ verdict,
385
+ reasons,
386
+ facts: found.facts,
387
+ };
388
+ }
389
+ const LABEL = {
390
+ avoid: '❌ AVOID',
391
+ review: '⚠️ REVIEW',
392
+ ok: '✅ OK',
393
+ unknown: '❔ UNKNOWN',
394
+ };
395
+ /** The report as the text an agent reads. */
396
+ function formatDependency(r) {
397
+ const head = `${LABEL[r.verdict]} — ${r.name}${r.version ? `@${r.version}` : ''} (${r.ecosystem})`;
398
+ const facts = Object.entries(r.facts)
399
+ .map(([k, v]) => `${k}=${String(v)}`)
400
+ .join(', ');
401
+ const advice = {
402
+ avoid: 'Do not add this dependency. Use an existing one, write the code, or ask the user.',
403
+ review: 'Tell the user about these points before adding it, or pick an alternative.',
404
+ ok: 'Nothing found. Pin the version you add.',
405
+ unknown: 'Nothing could be established. Do not treat this as safe; ask the user or retry.',
406
+ };
407
+ return [
408
+ head,
409
+ ...r.reasons.map(x => `- ${x}`),
410
+ ...(facts ? [`facts: ${facts}`] : []),
411
+ advice[r.verdict],
412
+ ].join('\n');
413
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * The Model Context Protocol, reduced to what a read-only checking server needs.
3
+ *
4
+ * Written by hand instead of pulling in the official SDK, on purpose. This is a
5
+ * security tool installed globally with the user's own rights; the SDK brings an
6
+ * HTTP framework and a schema library along, none of which a stdio server uses.
7
+ * The surface here is JSON-RPC 2.0 over newline-delimited stdio plus five methods,
8
+ * which is small enough to read in one sitting — and `tests/mcp/protocol.test.ts`
9
+ * pins every one of them.
10
+ *
11
+ * Kept free of I/O so it can be tested by feeding it objects: `serve.ts` owns the
12
+ * streams, this file owns what a message means.
13
+ */
14
+ /** Revisions we can speak, newest first. A client asking for another gets the newest. */
15
+ export declare const SUPPORTED_PROTOCOLS: readonly string[];
16
+ type JsonRpcId = string | number | null;
17
+ interface JsonRpcResponse {
18
+ jsonrpc: '2.0';
19
+ id: JsonRpcId;
20
+ result?: unknown;
21
+ error?: {
22
+ code: number;
23
+ message: string;
24
+ };
25
+ }
26
+ export declare const ERR: {
27
+ readonly parse: -32700;
28
+ readonly invalidRequest: -32600;
29
+ readonly methodNotFound: -32601;
30
+ readonly invalidParams: -32602;
31
+ readonly internal: -32603;
32
+ };
33
+ /** What a tool hands back; mirrored to the client as `content` plus `structuredContent`. */
34
+ export interface ToolResult {
35
+ text: string;
36
+ structured?: Record<string, unknown>;
37
+ /** The tool ran and reported a problem with its input or environment, not a finding. */
38
+ isError?: boolean;
39
+ }
40
+ export interface ToolDefinition {
41
+ name: string;
42
+ description: string;
43
+ inputSchema: Record<string, unknown>;
44
+ /** The tool changes files. Everything else is advertised as read-only. */
45
+ writes?: boolean;
46
+ /** The tool talks to services outside the machine (package registries, advisories). */
47
+ network?: boolean;
48
+ run(args: Record<string, unknown>): Promise<ToolResult>;
49
+ }
50
+ export interface ResourceDefinition {
51
+ uri: string;
52
+ name: string;
53
+ description: string;
54
+ mimeType: string;
55
+ read(): Promise<string>;
56
+ }
57
+ /** A ready-made request the client offers the user (a slash command in Claude Code). */
58
+ export interface PromptDefinition {
59
+ name: string;
60
+ description: string;
61
+ arguments?: Array<{
62
+ name: string;
63
+ description: string;
64
+ required?: boolean;
65
+ }>;
66
+ /** The text the agent is asked to act on, with the arguments filled in. */
67
+ render(args: Record<string, string>): string;
68
+ }
69
+ export interface ServerDefinition {
70
+ name: string;
71
+ version: string;
72
+ /** Shown to the model on connect — the place to say *when* to call the tools. */
73
+ instructions: string;
74
+ tools: ToolDefinition[];
75
+ resources: ResourceDefinition[];
76
+ prompts?: PromptDefinition[];
77
+ }
78
+ /**
79
+ * Pick the protocol revision for a session: the client's own if we speak it,
80
+ * otherwise our newest, which the client may then accept or hang up on.
81
+ */
82
+ export declare function negotiateProtocol(requested: unknown): string;
83
+ /**
84
+ * Answer one decoded message. Returns `null` for notifications, which by the
85
+ * JSON-RPC rules must not be answered — replying to `notifications/initialized`
86
+ * is a protocol violation some clients treat as fatal.
87
+ */
88
+ export declare function handleMessage(server: ServerDefinition, message: unknown): Promise<JsonRpcResponse | null>;
89
+ /** Decode one stdio line. A line that is not JSON gets a parse error, never a crash. */
90
+ export declare function handleLine(server: ServerDefinition, line: string): Promise<JsonRpcResponse | null>;
91
+ export {};
@@ -0,0 +1,181 @@
1
+ "use strict";
2
+ /**
3
+ * The Model Context Protocol, reduced to what a read-only checking server needs.
4
+ *
5
+ * Written by hand instead of pulling in the official SDK, on purpose. This is a
6
+ * security tool installed globally with the user's own rights; the SDK brings an
7
+ * HTTP framework and a schema library along, none of which a stdio server uses.
8
+ * The surface here is JSON-RPC 2.0 over newline-delimited stdio plus five methods,
9
+ * which is small enough to read in one sitting — and `tests/mcp/protocol.test.ts`
10
+ * pins every one of them.
11
+ *
12
+ * Kept free of I/O so it can be tested by feeding it objects: `serve.ts` owns the
13
+ * streams, this file owns what a message means.
14
+ */
15
+ Object.defineProperty(exports, "__esModule", { value: true });
16
+ exports.ERR = exports.SUPPORTED_PROTOCOLS = void 0;
17
+ exports.negotiateProtocol = negotiateProtocol;
18
+ exports.handleMessage = handleMessage;
19
+ exports.handleLine = handleLine;
20
+ /** Revisions we can speak, newest first. A client asking for another gets the newest. */
21
+ exports.SUPPORTED_PROTOCOLS = ['2025-06-18', '2025-03-26', '2024-11-05'];
22
+ exports.ERR = {
23
+ parse: -32700,
24
+ invalidRequest: -32600,
25
+ methodNotFound: -32601,
26
+ invalidParams: -32602,
27
+ internal: -32603,
28
+ };
29
+ function reply(id, result) {
30
+ return { jsonrpc: '2.0', id, result };
31
+ }
32
+ function failure(id, code, message) {
33
+ return { jsonrpc: '2.0', id, error: { code, message } };
34
+ }
35
+ function isRecord(value) {
36
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
37
+ }
38
+ /**
39
+ * Pick the protocol revision for a session: the client's own if we speak it,
40
+ * otherwise our newest, which the client may then accept or hang up on.
41
+ */
42
+ function negotiateProtocol(requested) {
43
+ return typeof requested === 'string' && exports.SUPPORTED_PROTOCOLS.includes(requested)
44
+ ? requested
45
+ : exports.SUPPORTED_PROTOCOLS[0];
46
+ }
47
+ async function callTool(server, id, params) {
48
+ const tool = server.tools.find(t => t.name === params.name);
49
+ if (!tool)
50
+ return failure(id, exports.ERR.invalidParams, `Unknown tool: ${String(params.name)}`);
51
+ const args = isRecord(params.arguments) ? params.arguments : {};
52
+ try {
53
+ const out = await tool.run(args);
54
+ return reply(id, {
55
+ content: [{ type: 'text', text: out.text }],
56
+ ...(out.structured && { structuredContent: out.structured }),
57
+ ...(out.isError && { isError: true }),
58
+ });
59
+ }
60
+ catch (err) {
61
+ // A tool that throws is a tool-level failure the model can read and react to,
62
+ // not a protocol error that would end the turn with an opaque message.
63
+ const message = err instanceof Error ? err.message : String(err);
64
+ return reply(id, {
65
+ content: [{ type: 'text', text: `${tool.name} failed: ${message}` }],
66
+ isError: true,
67
+ });
68
+ }
69
+ }
70
+ async function readResource(server, id, params) {
71
+ const resource = server.resources.find(r => r.uri === params.uri);
72
+ if (!resource)
73
+ return failure(id, exports.ERR.invalidParams, `Unknown resource: ${String(params.uri)}`);
74
+ return reply(id, {
75
+ contents: [{ uri: resource.uri, mimeType: resource.mimeType, text: await resource.read() }],
76
+ });
77
+ }
78
+ function getPrompt(server, id, params) {
79
+ const prompt = (server.prompts ?? []).find(p => p.name === params.name);
80
+ if (!prompt)
81
+ return failure(id, exports.ERR.invalidParams, `Unknown prompt: ${String(params.name)}`);
82
+ const given = isRecord(params.arguments) ? params.arguments : {};
83
+ const args = {};
84
+ for (const [key, value] of Object.entries(given))
85
+ args[key] = String(value);
86
+ const missing = (prompt.arguments ?? [])
87
+ .filter(a => a.required && !args[a.name])
88
+ .map(a => a.name);
89
+ if (missing.length > 0) {
90
+ return failure(id, exports.ERR.invalidParams, `Missing argument(s): ${missing.join(', ')}`);
91
+ }
92
+ return reply(id, {
93
+ description: prompt.description,
94
+ messages: [{ role: 'user', content: { type: 'text', text: prompt.render(args) } }],
95
+ });
96
+ }
97
+ /**
98
+ * Answer one decoded message. Returns `null` for notifications, which by the
99
+ * JSON-RPC rules must not be answered — replying to `notifications/initialized`
100
+ * is a protocol violation some clients treat as fatal.
101
+ */
102
+ async function handleMessage(server, message) {
103
+ if (!isRecord(message) || message.jsonrpc !== '2.0' || typeof message.method !== 'string') {
104
+ return failure(null, exports.ERR.invalidRequest, 'Not a JSON-RPC 2.0 request');
105
+ }
106
+ const request = message;
107
+ const isNotification = request.id === undefined;
108
+ const id = request.id ?? null;
109
+ const params = isRecord(request.params) ? request.params : {};
110
+ if (isNotification)
111
+ return null;
112
+ switch (request.method) {
113
+ case 'initialize':
114
+ return reply(id, {
115
+ protocolVersion: negotiateProtocol(params.protocolVersion),
116
+ capabilities: { tools: {}, resources: {}, prompts: {} },
117
+ serverInfo: { name: server.name, version: server.version },
118
+ instructions: server.instructions,
119
+ });
120
+ case 'ping':
121
+ return reply(id, {});
122
+ case 'tools/list':
123
+ return reply(id, {
124
+ tools: server.tools.map(({ name, description, inputSchema, writes, network }) => ({
125
+ name,
126
+ description,
127
+ inputSchema,
128
+ // Said explicitly so a client can approve the harmless calls without
129
+ // asking every time, and still ask for the one that edits files or the
130
+ // one that reaches the network.
131
+ annotations: {
132
+ readOnlyHint: writes !== true,
133
+ destructiveHint: false,
134
+ openWorldHint: network === true,
135
+ },
136
+ })),
137
+ });
138
+ case 'tools/call':
139
+ return callTool(server, id, params);
140
+ case 'resources/list':
141
+ return reply(id, {
142
+ resources: server.resources.map(({ uri, name, description, mimeType }) => ({
143
+ uri,
144
+ name,
145
+ description,
146
+ mimeType,
147
+ })),
148
+ });
149
+ case 'resources/read':
150
+ return readResource(server, id, params);
151
+ case 'prompts/list':
152
+ return reply(id, {
153
+ prompts: (server.prompts ?? []).map(({ name, description, arguments: args }) => ({
154
+ name,
155
+ description,
156
+ ...(args && { arguments: args }),
157
+ })),
158
+ });
159
+ case 'prompts/get':
160
+ return getPrompt(server, id, params);
161
+ default:
162
+ return failure(id, exports.ERR.methodNotFound, `Method not found: ${request.method}`);
163
+ }
164
+ }
165
+ /** Decode one stdio line. A line that is not JSON gets a parse error, never a crash. */
166
+ async function handleLine(server, line) {
167
+ let message;
168
+ try {
169
+ message = JSON.parse(line);
170
+ }
171
+ catch {
172
+ return failure(null, exports.ERR.parse, 'Parse error');
173
+ }
174
+ try {
175
+ return await handleMessage(server, message);
176
+ }
177
+ catch (err) {
178
+ const id = isRecord(message) && message.id !== undefined ? message.id : null;
179
+ return failure(id, exports.ERR.internal, err instanceof Error ? err.message : String(err));
180
+ }
181
+ }