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