@aimarket/warden 0.8.2 → 0.13.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 (117) hide show
  1. package/CHANGELOG.md +55 -1
  2. package/README-es.md +45 -10
  3. package/README-fr.md +45 -10
  4. package/README-ru.md +47 -10
  5. package/README-zh.md +45 -10
  6. package/README.md +47 -10
  7. package/dist/classifier.d.ts +79 -0
  8. package/dist/classifier.d.ts.map +1 -0
  9. package/dist/classifier.js +360 -0
  10. package/dist/classifier.js.map +1 -0
  11. package/dist/claude-hook.d.ts +53 -0
  12. package/dist/claude-hook.d.ts.map +1 -0
  13. package/dist/claude-hook.js +192 -0
  14. package/dist/claude-hook.js.map +1 -0
  15. package/dist/encoded-text.d.ts +7 -0
  16. package/dist/encoded-text.d.ts.map +1 -0
  17. package/dist/encoded-text.js +63 -0
  18. package/dist/encoded-text.js.map +1 -0
  19. package/dist/fold.d.ts +1 -2
  20. package/dist/fold.d.ts.map +1 -1
  21. package/dist/fold.js +3 -2
  22. package/dist/fold.js.map +1 -1
  23. package/dist/histor.d.ts +104 -0
  24. package/dist/histor.d.ts.map +1 -0
  25. package/dist/histor.js +86 -0
  26. package/dist/histor.js.map +1 -0
  27. package/dist/index.d.ts +6 -1
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +8 -4
  30. package/dist/index.js.map +1 -1
  31. package/dist/mcp-client.d.ts +58 -0
  32. package/dist/mcp-client.d.ts.map +1 -0
  33. package/dist/mcp-client.js +451 -0
  34. package/dist/mcp-client.js.map +1 -0
  35. package/dist/mcp-rpc.js +1 -1
  36. package/dist/mcp-server.js +8 -0
  37. package/dist/mcp-server.js.map +1 -1
  38. package/dist/mcp-tools.d.ts.map +1 -1
  39. package/dist/mcp-tools.js +29 -4
  40. package/dist/mcp-tools.js.map +1 -1
  41. package/dist/mtl.d.ts +28 -0
  42. package/dist/mtl.d.ts.map +1 -0
  43. package/dist/mtl.js +77 -0
  44. package/dist/mtl.js.map +1 -0
  45. package/dist/pin-store.d.ts.map +1 -1
  46. package/dist/pin-store.js +1 -0
  47. package/dist/pin-store.js.map +1 -1
  48. package/dist/pinning.d.ts +1 -1
  49. package/dist/pinning.d.ts.map +1 -1
  50. package/dist/pinning.js +12 -4
  51. package/dist/pinning.js.map +1 -1
  52. package/dist/result-screen.d.ts +19 -0
  53. package/dist/result-screen.d.ts.map +1 -0
  54. package/dist/result-screen.js +86 -0
  55. package/dist/result-screen.js.map +1 -0
  56. package/dist/scan-cli.d.ts +3 -0
  57. package/dist/scan-cli.d.ts.map +1 -0
  58. package/dist/scan-cli.js +36 -0
  59. package/dist/scan-cli.js.map +1 -0
  60. package/dist/scan-config.d.ts +72 -0
  61. package/dist/scan-config.d.ts.map +1 -0
  62. package/dist/scan-config.js +239 -0
  63. package/dist/scan-config.js.map +1 -0
  64. package/dist/scan-lock.d.ts +46 -0
  65. package/dist/scan-lock.d.ts.map +1 -0
  66. package/dist/scan-lock.js +113 -0
  67. package/dist/scan-lock.js.map +1 -0
  68. package/dist/scan-report.d.ts +21 -0
  69. package/dist/scan-report.d.ts.map +1 -0
  70. package/dist/scan-report.js +288 -0
  71. package/dist/scan-report.js.map +1 -0
  72. package/dist/scan.d.ts +177 -0
  73. package/dist/scan.d.ts.map +1 -0
  74. package/dist/scan.js +754 -0
  75. package/dist/scan.js.map +1 -0
  76. package/dist/static-scan.d.ts +4 -3
  77. package/dist/static-scan.d.ts.map +1 -1
  78. package/dist/static-scan.js +227 -11
  79. package/dist/static-scan.js.map +1 -1
  80. package/dist/tool-list.d.ts +34 -0
  81. package/dist/tool-list.d.ts.map +1 -0
  82. package/dist/tool-list.js +57 -0
  83. package/dist/tool-list.js.map +1 -0
  84. package/dist/toxic-flow.d.ts +33 -0
  85. package/dist/toxic-flow.d.ts.map +1 -0
  86. package/dist/toxic-flow.js +117 -0
  87. package/dist/toxic-flow.js.map +1 -0
  88. package/dist/types.d.ts +12 -0
  89. package/dist/types.d.ts.map +1 -1
  90. package/dist/wrap-cli.d.ts +3 -0
  91. package/dist/wrap-cli.d.ts.map +1 -1
  92. package/dist/wrap-cli.js +17 -6
  93. package/dist/wrap-cli.js.map +1 -1
  94. package/dist/wrap-state.js +2 -2
  95. package/dist/wrap-state.js.map +1 -1
  96. package/dist/wrap.d.ts.map +1 -1
  97. package/dist/wrap.js +22 -35
  98. package/dist/wrap.js.map +1 -1
  99. package/docs/gates.es.md +40 -7
  100. package/docs/gates.fr.md +39 -6
  101. package/docs/gates.md +38 -7
  102. package/docs/gates.ru.md +40 -7
  103. package/docs/gates.zh.md +10 -5
  104. package/docs/scan.es.md +199 -0
  105. package/docs/scan.fr.md +199 -0
  106. package/docs/scan.md +199 -0
  107. package/docs/scan.ru.md +199 -0
  108. package/docs/scan.zh.md +199 -0
  109. package/docs/scanner-comparison.es.md +209 -0
  110. package/docs/scanner-comparison.fr.md +213 -0
  111. package/docs/scanner-comparison.md +200 -0
  112. package/docs/scanner-comparison.ru.md +205 -0
  113. package/docs/scanner-comparison.zh.md +131 -0
  114. package/docs/security-hardening-language-boundary.md +306 -0
  115. package/package.json +10 -5
  116. package/scripts/check-quality-gate.mjs +64 -0
  117. package/scripts/quality-public-key.pem +3 -0
package/dist/scan.js ADDED
@@ -0,0 +1,754 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { join, resolve } from 'node:path';
4
+ import { Warden, StaticScanGate, ThreatFeed, staticScanRulesetRef, displaySafe, FilePinStore } from './index.js';
5
+ import { asPolicy } from './mcp-tools.js';
6
+ import { defaultStateDir } from './state.js';
7
+ import { packageVersion } from './mcp-rpc.js';
8
+ import { discoverServers, CLIENT_KINDS } from './scan-config.js';
9
+ import { fetchServer, McpClientError } from './mcp-client.js';
10
+ import { mtlToolSetDigest, MtlError } from './mtl.js';
11
+ import { historCheck, DEFAULT_HISTOR_URL } from './histor.js';
12
+ import { boundCapabilities, toxicFlows } from './toxic-flow.js';
13
+ import { inspectTools, ClassifierError } from './classifier.js';
14
+ import { LockStore, readLock, writeLock, lockEntryFor, diffTools, LOCK_VERSION } from './scan-lock.js';
15
+ export const SEVERITIES = ['info', 'low', 'medium', 'high', 'critical'];
16
+ // ── argument parsing ─────────────────────────────────────────────────────────
17
+ export const SCAN_USAGE = `Usage: warden-mcp scan [CONFIG...] [options]
18
+
19
+ Reads MCP client configs, connects to every server they start, and vets the
20
+ tool definitions before a model sees them. Exit 0 = nothing blocked,
21
+ 1 = a server was blocked (or, with --fail-on-error, could not be checked),
22
+ 2 = usage or config error.
23
+
24
+ Where to look (default: every known client file that exists)
25
+ CONFIG... client config files (.mcp.json, claude_desktop_config.json, mcp.json …)
26
+ --project only project files in --cwd (.mcp.json, .cursor/mcp.json, .vscode/mcp.json)
27
+ --client NAME only this client: ${CLIENT_KINDS.join(', ')} (repeatable)
28
+ --cwd DIR project directory (default: current directory)
29
+
30
+ How to connect
31
+ --no-launch do not start stdio servers; vet their launch line only
32
+ --public-only refuse remote servers that resolve to private/loopback/link-local addresses
33
+ --timeout MS per server (default 30000)
34
+ --concurrency N servers checked at once (default 4)
35
+
36
+ Verdicts
37
+ --fail-on SEVERITY block at this severity: info|low|medium|high|critical (default high)
38
+ --policy FILE strict JSON policy, as for wrap
39
+ --require-approval require an explicit operator pin/lock covering every field and identity
40
+ --lock FILE compare with reviewed definitions; a server missing from the lock is blocked
41
+ --update-lock write what the servers advertise now into --lock (refuses blocked servers)
42
+ --state-dir DIR where wrap keeps pins (read only here)
43
+ --feed URL --feed-key HEX signed threat feed, as for wrap
44
+ --histor ask the HISTOR log whether your servers serve you what they serve everyone
45
+ (remote: endpoint + digest; npm/PyPI stdio: package name + digest)
46
+ (sends the endpoint without query or credentials, and a digest; never tool text)
47
+ --histor-url URL default ${DEFAULT_HISTOR_URL}
48
+ --classifier-url URL --classifier-model NAME
49
+ also ask a model you choose (OpenAI-compatible: Ollama, vLLM, a hosted API)
50
+ whether each tool's text directs the model. Sends tool definitions to that URL.
51
+ Key, if any, from WARDEN_CLASSIFIER_API_KEY. Advisory unless --classifier-blocks.
52
+ --classifier-blocks enforce model verdicts; incomplete/error/uncertain inspection also refuses admission
53
+ --classifier-reasoning-effort none|low|medium|high
54
+ optional provider-supported budget; omitted uses the provider default
55
+
56
+ Output
57
+ --json JSON report on stdout instead of a table
58
+ --sarif FILE also write SARIF 2.1.0 (GitHub code scanning)
59
+ --markdown FILE also write a Markdown report (e.g. $GITHUB_STEP_SUMMARY)
60
+ --json-file FILE also write the JSON report to FILE
61
+ --fail-on-error a server that could not be checked fails the run too
62
+ --no-color`;
63
+ export async function parseScanArgs(argv, env = process.env) {
64
+ const opts = {
65
+ configs: [], project: false, cwd: process.cwd(), launch: true, publicOnly: false, timeoutMs: 30_000, concurrency: 4,
66
+ policy: asPolicy(undefined), updateLock: false, stateDir: defaultStateDir(), histor: false, historUrl: DEFAULT_HISTOR_URL,
67
+ format: 'table', failOnError: false, color: !!process.stdout.isTTY && !env.NO_COLOR,
68
+ };
69
+ let failOn, policyFile;
70
+ let requireApproval = false;
71
+ let classifierUrl, classifierModel, classifierBlocks = false;
72
+ let classifierReasoning;
73
+ const clients = [];
74
+ const value = (flag) => {
75
+ const v = argv.shift();
76
+ if (v === undefined || (v.startsWith('--') && v.length > 2))
77
+ throw new Error(`Missing value for ${flag}`);
78
+ return v;
79
+ };
80
+ while (argv.length) {
81
+ const arg = argv.shift();
82
+ if (!arg.startsWith('--')) {
83
+ opts.configs.push(arg);
84
+ continue;
85
+ }
86
+ switch (arg) {
87
+ case '--project':
88
+ opts.project = true;
89
+ break;
90
+ case '--client': {
91
+ const c = value(arg);
92
+ if (!CLIENT_KINDS.includes(c))
93
+ throw new Error(`--client must be one of ${CLIENT_KINDS.join(', ')}`);
94
+ clients.push(c);
95
+ break;
96
+ }
97
+ case '--cwd':
98
+ opts.cwd = resolve(value(arg));
99
+ break;
100
+ case '--no-launch':
101
+ opts.launch = false;
102
+ break;
103
+ case '--public-only':
104
+ opts.publicOnly = true;
105
+ break;
106
+ case '--timeout': {
107
+ const n = Number(value(arg));
108
+ if (!Number.isInteger(n) || n < 100 || n > 600_000)
109
+ throw new Error('--timeout must be 100–600000 ms');
110
+ opts.timeoutMs = n;
111
+ break;
112
+ }
113
+ case '--concurrency': {
114
+ const n = Number(value(arg));
115
+ if (!Number.isInteger(n) || n < 1 || n > 32)
116
+ throw new Error('--concurrency must be 1–32');
117
+ opts.concurrency = n;
118
+ break;
119
+ }
120
+ case '--fail-on': {
121
+ const s = value(arg);
122
+ if (!SEVERITIES.includes(s))
123
+ throw new Error(`--fail-on must be one of ${SEVERITIES.join(', ')}`);
124
+ failOn = s;
125
+ break;
126
+ }
127
+ case '--policy':
128
+ policyFile = value(arg);
129
+ break;
130
+ case '--lock':
131
+ opts.lock = resolve(value(arg));
132
+ break;
133
+ case '--update-lock':
134
+ opts.updateLock = true;
135
+ break;
136
+ case '--state-dir':
137
+ opts.stateDir = value(arg);
138
+ break;
139
+ case '--feed':
140
+ opts.feed = value(arg);
141
+ break;
142
+ case '--feed-key':
143
+ opts.feedKey = value(arg);
144
+ break;
145
+ case '--histor':
146
+ opts.histor = true;
147
+ break;
148
+ case '--histor-url':
149
+ opts.historUrl = value(arg);
150
+ opts.histor = true;
151
+ break;
152
+ case '--classifier-url':
153
+ classifierUrl = value(arg);
154
+ break;
155
+ case '--classifier-model':
156
+ classifierModel = value(arg);
157
+ break;
158
+ case '--classifier-reasoning-effort':
159
+ classifierReasoning = value(arg);
160
+ break;
161
+ case '--require-approval':
162
+ requireApproval = true;
163
+ break;
164
+ case '--classifier-blocks':
165
+ classifierBlocks = true;
166
+ break;
167
+ case '--json':
168
+ opts.format = 'json';
169
+ break;
170
+ case '--sarif':
171
+ opts.sarif = resolve(value(arg));
172
+ break;
173
+ case '--markdown':
174
+ opts.markdown = resolve(value(arg));
175
+ break;
176
+ case '--json-file':
177
+ opts.jsonFile = resolve(value(arg));
178
+ break;
179
+ case '--fail-on-error':
180
+ opts.failOnError = true;
181
+ break;
182
+ case '--no-color':
183
+ opts.color = false;
184
+ break;
185
+ case '--help':
186
+ case '-h': throw new UsageRequested();
187
+ default: throw new Error(`Unknown option ${arg}`);
188
+ }
189
+ }
190
+ if (clients.length)
191
+ opts.clients = clients;
192
+ if (opts.updateLock && !opts.lock)
193
+ throw new Error('--update-lock needs --lock FILE');
194
+ if (opts.updateLock && !opts.launch)
195
+ throw new Error('--update-lock needs the tool definitions, so it cannot be combined with --no-launch');
196
+ if (opts.project && opts.configs.length)
197
+ throw new Error('--project and explicit CONFIG files are alternatives');
198
+ if (policyFile) {
199
+ // Same strict reading as `wrap --policy`: a typo must stop the run, not silently relax it.
200
+ const raw = JSON.parse(await readFile(policyFile, 'utf8'));
201
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
202
+ throw new Error('policy must be an object');
203
+ for (const key of Object.keys(raw))
204
+ if (!['blockAtSeverity', 'pinToolDefs', 'allowUnknownServers', 'sensitiveToolPatterns', 'requireApproval', 'capabilityBindings'].includes(key))
205
+ throw new Error(`Unknown policy field ${key}`);
206
+ opts.policy = asPolicy(raw);
207
+ }
208
+ if (requireApproval)
209
+ opts.policy.requireApproval = true;
210
+ if (failOn)
211
+ opts.policy = { ...opts.policy, blockAtSeverity: failOn };
212
+ if (!!classifierUrl !== !!classifierModel)
213
+ throw new Error('--classifier-url and --classifier-model go together');
214
+ if (classifierBlocks && !classifierUrl)
215
+ throw new Error('--classifier-blocks needs --classifier-url and --classifier-model');
216
+ if (classifierReasoning && (!classifierUrl || !['none', 'low', 'medium', 'high'].includes(classifierReasoning)))
217
+ throw new Error('--classifier-reasoning-effort needs a classifier and must be none, low, medium or high');
218
+ if (classifierUrl && classifierModel) {
219
+ const cu = new URL(classifierUrl);
220
+ if (cu.protocol !== 'https:' && cu.protocol !== 'http:')
221
+ throw new Error('--classifier-url must be http(s)');
222
+ if (cu.username || cu.password)
223
+ throw new Error('--classifier-url must not carry credentials; use WARDEN_CLASSIFIER_API_KEY');
224
+ opts.classifier = { url: classifierUrl, model: classifierModel, apiKey: env.WARDEN_CLASSIFIER_API_KEY || undefined, blocks: classifierBlocks,
225
+ ...(classifierReasoning ? { reasoningEffort: classifierReasoning } : {}) };
226
+ }
227
+ const u = new URL(opts.historUrl);
228
+ if (u.protocol !== 'https:' && u.protocol !== 'http:')
229
+ throw new Error('--histor-url must be http(s)');
230
+ return opts;
231
+ }
232
+ export class UsageRequested extends Error {
233
+ constructor() { super(SCAN_USAGE); }
234
+ }
235
+ // ── display helpers ──────────────────────────────────────────────────────────
236
+ const SECRET_FLAG = /(key|token|secret|password|passwd|pwd|auth|bearer|credential)/i;
237
+ const SECRET_SHAPE = /^(?:sk-|sk_|ghp_|gho_|ghs_|github_pat_|xox[abpr]-|aimk_|AKIA|eyJ)[A-Za-z0-9._-]{8,}|^[A-Za-z0-9+/_-]{32,}={0,2}$/;
238
+ /** A path segment or argument that looks like it carries a credential. */
239
+ export function looksSecret(text) {
240
+ if (SECRET_SHAPE.test(text))
241
+ return true;
242
+ return text.length >= 24 && /[A-Za-z]/.test(text) && /\d/.test(text) && /^[A-Za-z0-9_-]+$/.test(text);
243
+ }
244
+ /** The launch line with credentials replaced, for terminals, reports and SARIF. */
245
+ export function redactLaunch(server) {
246
+ const ref = server.ref;
247
+ if (ref.url)
248
+ return redactUrl(ref.url);
249
+ const out = [];
250
+ const args = ref.args ?? [];
251
+ for (let i = 0; i < args.length; i++) {
252
+ const a = args[i];
253
+ const eq = a.indexOf('=');
254
+ if (a.startsWith('--') && eq > 0 && SECRET_FLAG.test(a.slice(0, eq))) {
255
+ out.push(a.slice(0, eq + 1) + '***');
256
+ continue;
257
+ }
258
+ if (i > 0 && args[i - 1].startsWith('-') && SECRET_FLAG.test(args[i - 1])) {
259
+ out.push('***');
260
+ continue;
261
+ }
262
+ out.push(looksSecret(a) ? '***' : a);
263
+ }
264
+ return [ref.command ?? '', ...out].join(' ').trim();
265
+ }
266
+ export function redactUrl(raw) {
267
+ try {
268
+ const u = new URL(raw);
269
+ u.username = '';
270
+ u.password = '';
271
+ const q = [...u.searchParams.keys()];
272
+ u.search = '';
273
+ for (const k of q)
274
+ u.searchParams.set(k, '***');
275
+ u.pathname = u.pathname.split('/').map(seg => looksSecret(decodeURIComponent(seg)) ? '***' : seg).join('/');
276
+ return u.toString();
277
+ }
278
+ catch {
279
+ return '(unparseable URL)';
280
+ }
281
+ }
282
+ /** The endpoint HISTOR may be asked about, or why it is not sent. */
283
+ export function historEndpoint(raw) {
284
+ let u;
285
+ try {
286
+ u = new URL(raw);
287
+ }
288
+ catch {
289
+ return { reason: 'unparseable URL' };
290
+ }
291
+ if (u.protocol !== 'https:' && u.protocol !== 'http:')
292
+ return { reason: 'not an http(s) endpoint' };
293
+ const host = u.hostname.replace(/^\[|\]$/g, '').toLowerCase();
294
+ if (host === 'localhost' || host.endsWith('.localhost') || /\.(local|internal|lan|home|corp|intranet|test|example|invalid)$/.test(host) || !host.includes('.')) {
295
+ return { reason: 'private host name' };
296
+ }
297
+ if (/^[\d.]+$/.test(host) || host.includes(':')) {
298
+ // An address literal: only a public one names something HISTOR could have crawled.
299
+ const v4 = host.split('.').map(Number);
300
+ if (host.includes(':') || v4[0] === 10 || v4[0] === 127 || (v4[0] === 192 && v4[1] === 168) || (v4[0] === 172 && v4[1] >= 16 && v4[1] <= 31) || (v4[0] === 169 && v4[1] === 254))
301
+ return { reason: 'private address' };
302
+ }
303
+ if (u.pathname.split('/').some(seg => seg && looksSecret(decodeURIComponent(seg))))
304
+ return { reason: 'the path looks like it carries a credential' };
305
+ return { endpoint: `${u.protocol}//${u.host}${u.pathname}` };
306
+ }
307
+ const NPM_PACKAGE = /^(@[a-z0-9][a-z0-9._~-]{0,100}\/)?[a-z0-9][a-z0-9._~-]{0,100}$/;
308
+ const PYPI_PACKAGE = /^[A-Za-z0-9]([A-Za-z0-9._-]{0,126}[A-Za-z0-9])?$/;
309
+ /** Launcher flags that take a value, so the value is not mistaken for the package. */
310
+ const VALUE_FLAGS = new Set(['--python', '-p', '--with', '-w', '--with-requirements', '--cache', '--node-options', '--call', '-c', '--spec', '--pip-args']);
311
+ /**
312
+ * The npm or PyPI package a stdio launch runs, as HISTOR names it (`npm:<name>`,
313
+ * `pypi:<normalised name>`), or why it is not sent. Only a package from the public
314
+ * registry is named: a local path, a git or tarball URL, or a private registry
315
+ * (`--registry`, `--index-url`) stays on this machine. The version is not sent —
316
+ * the digest already tells HISTOR which of the versions it observed you run.
317
+ */
318
+ export function historPackage(command, args) {
319
+ const base = (command.split(/[\\/]/).pop() ?? '').toLowerCase().replace(/\.(cmd|exe|ps1)$/, '');
320
+ let rest = [...args];
321
+ let kind;
322
+ if (base === 'npx' || base === 'bunx')
323
+ kind = 'npm';
324
+ else if ((base === 'npm' && rest[0] === 'exec') || ((base === 'pnpm' || base === 'yarn') && rest[0] === 'dlx') || (base === 'bun' && rest[0] === 'x')) {
325
+ kind = 'npm';
326
+ rest = rest.slice(1);
327
+ }
328
+ else if (base === 'uvx')
329
+ kind = 'pypi';
330
+ else if (base === 'uv' && rest[0] === 'tool' && rest[1] === 'run') {
331
+ kind = 'pypi';
332
+ rest = rest.slice(2);
333
+ }
334
+ else if (base === 'pipx' && rest[0] === 'run') {
335
+ kind = 'pypi';
336
+ rest = rest.slice(1);
337
+ }
338
+ else
339
+ return { reason: 'not started from a package manager (npx, uvx …)' };
340
+ let spec;
341
+ for (let i = 0; i < rest.length; i++) {
342
+ const a = rest[i];
343
+ if (/^--(registry|index-url|index|extra-index-url|find-links)(=|$)|^-i$|^-f$/.test(a))
344
+ return { reason: 'a private registry' };
345
+ if (a === '--') {
346
+ spec = rest[i + 1];
347
+ break;
348
+ }
349
+ if (a === '--package' || a === '--from' || (kind === 'npm' && a === '-p')) {
350
+ spec = rest[i + 1];
351
+ break;
352
+ }
353
+ if (a.startsWith('--package=') || a.startsWith('--from=')) {
354
+ spec = a.slice(a.indexOf('=') + 1);
355
+ break;
356
+ }
357
+ if (a.startsWith('-')) {
358
+ if (VALUE_FLAGS.has(a))
359
+ i++;
360
+ continue;
361
+ }
362
+ spec = a;
363
+ break;
364
+ }
365
+ if (!spec)
366
+ return { reason: 'no package in the launch line' };
367
+ if (spec.includes('://') || /^(file:|git[+:]|github:|link:|workspace:|\.|\/|~|[A-Za-z]:\\)/.test(spec))
368
+ return { reason: 'not a registry package' };
369
+ if (kind === 'npm') {
370
+ const at = spec.indexOf('@', 1);
371
+ const name = at > 0 ? spec.slice(0, at) : spec;
372
+ return NPM_PACKAGE.test(name) ? { package: `npm:${name}` } : { reason: 'not a registry package' };
373
+ }
374
+ const name = spec.split(/[[=<>!~@; ]/)[0];
375
+ return PYPI_PACKAGE.test(name) ? { package: `pypi:${name.toLowerCase().replace(/[-_.]+/g, '-')}` } : { reason: 'not a registry package' };
376
+ }
377
+ /**
378
+ * What HISTOR knows about a package beyond its tools: a name made to be taken for a popular one,
379
+ * and the marks of a stolen publishing token on the version it observed. All advisory.
380
+ */
381
+ export function packageFindings(answer) {
382
+ const out = [];
383
+ const look = answer.packageLookalike;
384
+ if (look && typeof look.of === 'string') {
385
+ out.push({ gate: 'histor', severity: 'medium', code: 'HISTOR_PACKAGE_LOOKALIKE', advisory: true,
386
+ message: `This package is named like ${displaySafe(look.of, 120)} (${Number(look.weekly ?? 0).toLocaleString('en')} downloads a week; this one: ${Number(look.ownWeekly ?? 0).toLocaleString('en')}) — ${displaySafe(String(look.how ?? 'a similar name'), 80)}. Make sure it is the one you meant to install.` });
387
+ }
388
+ const sig = answer.target?.packageSignals;
389
+ const flags = Array.isArray(sig?.flags) ? sig.flags.filter((f) => typeof f === 'string') : [];
390
+ const v = displaySafe(String(sig?.version ?? '?'), 40), pv = displaySafe(String(sig?.previousVersion ?? '?'), 40);
391
+ if (flags.includes('provenance-lost'))
392
+ out.push({ gate: 'histor', severity: 'medium', code: 'HISTOR_PACKAGE_PROVENANCE_LOST', advisory: true,
393
+ message: `Version ${v} has no attested build, though ${pv} had one: it was not published by the project's CI. A stolen publishing token looks like this.` });
394
+ if (flags.includes('install-scripts-added'))
395
+ out.push({ gate: 'histor', severity: 'medium', code: 'HISTOR_PACKAGE_INSTALL_SCRIPTS', advisory: true,
396
+ message: `Version ${v} runs install scripts (${(sig?.installScripts ?? []).map(s => displaySafe(String(s), 20)).join(', ')}) that ${pv} did not: they run on install, before any check.` });
397
+ if (flags.includes('publisher-changed'))
398
+ out.push({ gate: 'histor', severity: 'low', code: 'HISTOR_PACKAGE_PUBLISHER_CHANGED', advisory: true,
399
+ message: `Version ${v} was published by ${displaySafe(String(sig?.publisher ?? '?'), 60)}, ${pv} by ${displaySafe(String(sig?.previousPublisher ?? '?'), 60)}.` });
400
+ if (flags.includes('new-dependencies'))
401
+ out.push({ gate: 'histor', severity: 'low', code: 'HISTOR_PACKAGE_NEW_DEPENDENCIES', advisory: true,
402
+ message: `Version ${v} added dependencies ${pv} did not have: ${(sig?.newDependencies ?? []).slice(0, 8).map(d => displaySafe(String(d), 60)).join(', ')}.` });
403
+ out.push(...behaviourFindings(sig?.behaviour, v));
404
+ return out;
405
+ }
406
+ const list = (xs, n = 6, len = 80) => (Array.isArray(xs) ? xs : []).filter((x) => typeof x === 'string').slice(0, n).map(x => displaySafe(x, len)).join(', ');
407
+ const arr = (xs) => (Array.isArray(xs) ? xs.filter((x) => typeof x === 'string') : []);
408
+ /**
409
+ * What the package did in HISTOR's sandbox (gVisor's own trace, decoy credentials, canary tool
410
+ * calls). Reading a decoy or writing where it would persist is the loud part; contacting its own
411
+ * API during a call is what API servers do and is not a finding, nor is loading .env from the
412
+ * working directory, which dotenv does for half the ecosystem.
413
+ */
414
+ export function behaviourFindings(behaviour, version) {
415
+ if (!behaviour || typeof behaviour !== 'object')
416
+ return [];
417
+ const out = [];
418
+ const phase = (k) => (behaviour[k] && typeof behaviour[k] === 'object' ? behaviour[k] : {});
419
+ const names = { installScripts: 'its install scripts', startup: 'at startup', calls: 'when its tools were called' };
420
+ for (const key of ['installScripts', 'startup', 'calls']) {
421
+ const b = phase(key);
422
+ const secrets = arr(b.decoys).filter(d => !d.startsWith('.env'));
423
+ if (secrets.length)
424
+ out.push({ gate: 'histor', severity: 'high', code: 'HISTOR_PACKAGE_READS_SECRETS', advisory: true,
425
+ message: `In HISTOR's sandbox, version ${version} opened decoy credentials ${names[key]}: ${list(secrets)}.` });
426
+ if (arr(b.writes).length)
427
+ out.push({ gate: 'histor', severity: 'high', code: 'HISTOR_PACKAGE_PERSISTENCE', advisory: true,
428
+ message: `In HISTOR's sandbox, version ${version} wrote ${names[key]} where it would outlive the session: ${list(b.writes)}.` });
429
+ }
430
+ const start = phase('startup');
431
+ if (arr(start.network).length || arr(start.lookups).length)
432
+ out.push({ gate: 'histor', severity: 'medium', code: 'HISTOR_PACKAGE_STARTUP_NETWORK', advisory: true,
433
+ message: `In HISTOR's sandbox, version ${version} reached out at startup, before any tool was called: ${list(arr(start.network).length ? start.network : start.lookups)}.` });
434
+ const spawned = [...arr(start.exec), ...arr(phase('calls').exec)];
435
+ if (spawned.length)
436
+ out.push({ gate: 'histor', severity: 'low', code: 'HISTOR_PACKAGE_STARTS_PROGRAMS', advisory: true,
437
+ message: `In HISTOR's sandbox, version ${version} started programs: ${list(spawned, 6, 100)}.` });
438
+ const scripts = phase('installScripts');
439
+ if (arr(scripts.network).length || arr(scripts.lookups).length || arr(scripts.exec).length)
440
+ out.push({ gate: 'histor', severity: 'medium', code: 'HISTOR_PACKAGE_INSTALL_BEHAVIOUR', advisory: true,
441
+ message: `Installing version ${version} runs scripts (${list(scripts.packages, 4, 60)}) that ${arr(scripts.network).length || arr(scripts.lookups).length ? `reach ${list(arr(scripts.network).length ? scripts.network : scripts.lookups)}` : `start ${list(scripts.exec, 4, 100)}`} — before any check can run.` });
442
+ return out;
443
+ }
444
+ function historWatchUrl(opts, servers) {
445
+ if (!opts.histor)
446
+ return {};
447
+ const ids = [...new Set(servers.map(s => s.histor?.targetId).filter((x) => !!x))].sort().slice(0, 100);
448
+ return ids.length ? { historWatch: `${opts.historUrl.replace(/\/+$/, '')}/feed.xml?watch=${ids.join(',')}` } : {};
449
+ }
450
+ // ── the scan ─────────────────────────────────────────────────────────────────
451
+ async function pool(items, n, fn) {
452
+ const out = new Array(items.length);
453
+ let next = 0;
454
+ await Promise.all(Array.from({ length: Math.min(n, items.length) }, async () => {
455
+ while (next < items.length) {
456
+ const i = next++;
457
+ out[i] = await fn(items[i]);
458
+ }
459
+ }));
460
+ return out;
461
+ }
462
+ const READ_ONLY = (store) => ({ getPin: id => store.getPin(id), putPin: async () => { } });
463
+ const NO_PINS = { getPin: async () => undefined, putPin: async () => { } };
464
+ function blocking(f, policy) {
465
+ return !f.advisory && SEVERITIES.indexOf(f.severity) >= SEVERITIES.indexOf(policy.blockAtSeverity);
466
+ }
467
+ export async function runScan(opts, deps = {}) {
468
+ const version = packageVersion();
469
+ const d = { home: homedir(), cwd: opts.cwd, platform: process.platform, env: process.env, ...deps.discovery };
470
+ let results;
471
+ if (opts.project) {
472
+ const files = ['.mcp.json', join('.cursor', 'mcp.json'), join('.vscode', 'mcp.json')].map(f => join(opts.cwd, f));
473
+ const { existsSync } = await import('node:fs');
474
+ results = await discoverServers(files.filter(f => existsSync(f)), d);
475
+ // An explicit file list makes guessClient decide; mark scope honestly.
476
+ for (const r of results) {
477
+ r.source.scope = 'project';
478
+ for (const s of r.servers)
479
+ s.scope = 'project';
480
+ }
481
+ if (!results.length)
482
+ results = [];
483
+ }
484
+ else {
485
+ results = await discoverServers(opts.configs.length ? opts.configs : undefined, d, opts.clients);
486
+ }
487
+ const feed = new ThreatFeed({ feedPublicKey: opts.feedKey, stateDir: opts.stateDir });
488
+ await feed.load(opts.feed);
489
+ const lockFile = opts.lock ? await readLock(opts.lock) : undefined;
490
+ const lockStore = opts.lock ? new LockStore(lockFile?.servers ?? {}) : undefined;
491
+ const wrapPins = READ_ONLY(new FilePinStore(join(opts.stateDir, 'pins')));
492
+ const now = (deps.now ?? (() => new Date()))().toISOString();
493
+ const semanticReviews = new Map();
494
+ const all = results.flatMap(r => r.servers);
495
+ // The same server listed by two clients is checked once per entry: they are separate launches.
496
+ const servers = await pool(all, opts.concurrency, async (server) => {
497
+ const base = {
498
+ id: server.id, key: server.key, client: server.client, source: server.source, scope: server.scope,
499
+ transport: server.ref.transport, wrapped: server.wrapped, launch: redactLaunch(server),
500
+ status: 'skipped', allow: null, findings: [], allowedTools: [], blockedTools: [],
501
+ };
502
+ if (server.skipped)
503
+ return { ...base, skipped: server.skipped };
504
+ const store = lockStore ?? (server.wrapped ? wrapPins : NO_PINS);
505
+ const warden = Warden.create({ policy: opts.policy, threatFeed: feed, store });
506
+ const launchVerdict = await warden.vetLaunch(server.ref);
507
+ const lockEntry = lockStore ? lockStore.entries.get(server.id) : undefined;
508
+ const lockMissing = lockStore && !lockEntry ? {
509
+ gate: 'lock', severity: 'high', code: 'LOCK_MISSING',
510
+ message: `Server "${displaySafe(server.id)}" is not in the lock file, so its tool definitions were never reviewed. Run scan --lock … --update-lock and commit the lock.`,
511
+ } : undefined;
512
+ if (!launchVerdict.allow) {
513
+ return { ...base, status: 'blocked-at-launch', allow: false, score: launchVerdict.score, decidedBy: launchVerdict.decidedBy,
514
+ findings: [...launchVerdict.findings, ...(lockMissing ? [lockMissing] : [])], lock: lockStore ? { state: lockEntry ? 'drift' : 'missing' } : undefined };
515
+ }
516
+ if (server.ref.transport === 'stdio' && !opts.launch) {
517
+ const findings = [...launchVerdict.findings, ...(lockMissing ? [lockMissing] : [])];
518
+ if (opts.policy.requireApproval)
519
+ findings.push({ gate: 'pinning', severity: 'high', code: 'TOOL_DEF_INSPECTION_REQUIRED',
520
+ message: 'Strict admission cannot verify the approved definitions without tools/list; launch-only inspection is incomplete.' });
521
+ return { ...base, status: 'launch-only', allow: !lockMissing && !opts.policy.requireApproval, score: launchVerdict.score, findings,
522
+ ...(lockMissing ? { decidedBy: 'lock' } : opts.policy.requireApproval ? { decidedBy: 'pinning' } : {}), lock: lockStore ? { state: lockEntry ? 'match' : 'missing' } : undefined };
523
+ }
524
+ let fetched;
525
+ try {
526
+ fetched = deps.fetch ? await deps.fetch(server, opts)
527
+ : await fetchServer(server.ref, { timeoutMs: opts.timeoutMs, clientVersion: version, publicOnly: opts.publicOnly, headers: server.headers, cwd: opts.cwd });
528
+ }
529
+ catch (err) {
530
+ const msg = err instanceof McpClientError ? err.message : err instanceof Error ? err.message : String(err);
531
+ return { ...base, status: 'error', error: displaySafe(msg, 400), findings: lockMissing ? [lockMissing] : [], allow: lockMissing ? false : null, ...(lockMissing ? { decidedBy: 'lock' } : {}) };
532
+ }
533
+ let inspection, inspectionError;
534
+ if (opts.classifier && fetched.tools.length) {
535
+ try {
536
+ inspection = await inspectTools(fetched.tools, { ...opts.classifier, requireComplete: opts.classifier.blocks });
537
+ if (opts.classifier.blocks)
538
+ semanticReviews.set(fetched.tools, inspection);
539
+ }
540
+ catch (err) {
541
+ inspectionError = err;
542
+ }
543
+ }
544
+ const reviewedWarden = Warden.create({ policy: opts.policy, threatFeed: feed, store,
545
+ semanticReview: semanticReviews.get(fetched.tools) });
546
+ const verdict = await reviewedWarden.vet(server.ref, fetched.tools);
547
+ const findings = [...verdict.findings];
548
+ let allow = verdict.allow, decidedBy = verdict.decidedBy, score = verdict.score;
549
+ if (fetched.instructions) {
550
+ // The same check wrap applies before passing initialize.instructions to the client.
551
+ const scan = await new Warden({ policy: opts.policy, gates: [new StaticScanGate()] })
552
+ .vet(server.ref, [{ name: 'initialize.instructions', description: fetched.instructions, inputSchema: { type: 'object' } }]);
553
+ for (const f of scan.findings)
554
+ findings.push({ ...f, tool: 'initialize.instructions' });
555
+ if (!scan.allow) {
556
+ if (allow)
557
+ decidedBy = 'static-scan';
558
+ allow = false;
559
+ }
560
+ score *= scan.score;
561
+ }
562
+ if (lockMissing) {
563
+ findings.push(lockMissing);
564
+ if (allow)
565
+ decidedBy = 'lock';
566
+ allow = false;
567
+ }
568
+ let mtlDigest, mtlError;
569
+ try {
570
+ mtlDigest = mtlToolSetDigest(fetched.tools);
571
+ }
572
+ catch (err) {
573
+ mtlError = err instanceof MtlError ? err.code : String(err);
574
+ }
575
+ let histor;
576
+ if (opts.histor) {
577
+ const target = server.ref.url
578
+ ? historEndpoint(server.ref.url)
579
+ : historPackage(server.ref.command ?? '', server.ref.args ?? []);
580
+ if (!target.endpoint && !target.package)
581
+ histor = { notSent: target.reason };
582
+ else {
583
+ try {
584
+ const query = target.endpoint ? { endpoint: target.endpoint } : { package: target.package };
585
+ const answer = await historCheck(opts.historUrl, { ...query, ...(mtlDigest ? { toolSetDigest: mtlDigest } : {}) }, { fetchImpl: deps.historFetch, userAgent: `warden-scan/${version}` });
586
+ const seenVersion = typeof answer.target?.packageVersion === 'string' ? displaySafe(answer.target.packageVersion, 64) : undefined;
587
+ const targetId = typeof answer.target?.id === 'string' && /^[0-9a-f]{16}$/.test(answer.target.id) ? answer.target.id : undefined;
588
+ histor = { ...(target.package ? { package: target.package } : {}), ...(seenVersion ? { packageVersion: seenVersion } : {}), ...(targetId ? { targetId } : {}),
589
+ match: String(answer.match), note: typeof answer.note === 'string' ? displaySafe(answer.note, 400) : undefined,
590
+ page: answer.target?.page, unchangedSince: answer.observed?.unchangedSince, changes: answer.observed?.changes };
591
+ if (answer.match === 'different')
592
+ findings.push({ gate: 'histor', severity: 'medium', code: 'HISTOR_UNSEEN_TOOLSET', advisory: true,
593
+ message: target.package
594
+ ? `HISTOR has never observed the tool set this package gave you (${mtlDigest}) in any version it ran${seenVersion ? ` (latest it ran: ${seenVersion})` : ''}. Either you run a version HISTOR has not reached yet, or the package on this machine is not the one published.`
595
+ : `HISTOR has never observed the tool set this server served you (${mtlDigest}). Either it changed after the last crawl, or it serves you something it does not serve the public crawler.` });
596
+ if (target.package)
597
+ findings.push(...packageFindings(answer));
598
+ if (answer.match === 'previously-observed')
599
+ findings.push({ gate: 'histor', severity: 'low', code: 'HISTOR_OLDER_TOOLSET', advisory: true,
600
+ message: target.package
601
+ ? `You run an older version of this package: HISTOR observed this tool set before${seenVersion ? `, and version ${seenVersion} serves a different one` : ''}.`
602
+ : 'HISTOR observed this tool set earlier, but the server now serves the public a different one.' });
603
+ // The log's own classifier verdict on the set you were served, when it holds one: advisory, never a block.
604
+ const cls = answer.classifier;
605
+ if (answer.match === 'same' && cls && cls.status === 'classified' && Array.isArray(cls.findings)) {
606
+ for (const f of cls.findings.slice(0, 50)) {
607
+ const cats = Array.isArray(f.categories) ? f.categories.filter((c) => typeof c === 'string').slice(0, 4) : [];
608
+ if (!cats.length || typeof f.tool !== 'string')
609
+ continue;
610
+ findings.push({ gate: 'histor', severity: 'medium', code: 'HISTOR_CLASSIFIER', advisory: true, tool: f.tool,
611
+ message: `HISTOR's classifier (${displaySafe(String(cls.model ?? 'model'), 60)}) reads this tool as ${cats.map(c => displaySafe(c, 30)).join(', ')}: ${displaySafe(String(f.reason ?? ''), 300)}` });
612
+ }
613
+ }
614
+ }
615
+ catch (err) {
616
+ histor = { error: displaySafe(err instanceof Error ? err.message : String(err), 200) };
617
+ }
618
+ }
619
+ }
620
+ let classifier;
621
+ if (opts.classifier && fetched.tools.length) {
622
+ try {
623
+ if (!inspection)
624
+ throw inspectionError ?? new ClassifierError("inspection unavailable");
625
+ const verdicts = inspection.findings;
626
+ classifier = { model: opts.classifier.model, flagged: verdicts.length, ...(inspection.retried ? { retried: inspection.retried } : {}) };
627
+ if (inspection.incomplete.length) {
628
+ classifier.error = inspection.incomplete.map(r => r.error).join("; ").slice(0, 400);
629
+ classifier.incompleteTools = inspection.incomplete.map(r => r.tool);
630
+ for (const r of inspection.incomplete)
631
+ findings.push({ gate: "classifier", code: "CLASSIFIER_INCOMPLETE", severity: "high", tool: r.tool,
632
+ message: `Semantic inspection incomplete: ${displaySafe(r.error, 200)}. This is not an attack verdict.`, ...(!opts.classifier.blocks ? { advisory: true } : {}) });
633
+ if (opts.classifier.blocks) {
634
+ if (allow)
635
+ decidedBy = "classifier";
636
+ allow = false;
637
+ score = 0;
638
+ }
639
+ }
640
+ for (const v of verdicts) {
641
+ const f = { gate: 'classifier', code: 'TOOL_DEF_CLASSIFIER', severity: v.severity, tool: v.tool,
642
+ message: `Model ${displaySafe(opts.classifier.model, 60)} reads this tool as ${v.categories.join(', ')}: ${displaySafe(v.reason, 300)} — ${v.evidenceFields ? "source fields " + v.evidenceFields.map(x => displaySafe(x, 60)).join(", ") + "; preview" : "at"} "${displaySafe(v.quote, 200)}"` };
643
+ if (!opts.classifier.blocks)
644
+ f.advisory = true;
645
+ findings.push(f);
646
+ if (opts.classifier.blocks && blocking(f, opts.policy)) {
647
+ if (allow)
648
+ decidedBy = 'classifier';
649
+ allow = false;
650
+ }
651
+ }
652
+ }
653
+ catch (err) {
654
+ classifier = { model: opts.classifier.model, flagged: 0, error: displaySafe(err instanceof ClassifierError ? err.message : String(err), 200) };
655
+ findings.push({ gate: 'classifier', code: 'CLASSIFIER_INCOMPLETE', severity: 'high',
656
+ message: `Semantic inspection incomplete: ${classifier.error}. This is not an attack verdict.`,
657
+ ...(!opts.classifier.blocks ? { advisory: true } : {}) });
658
+ if (opts.classifier.blocks) {
659
+ if (allow)
660
+ decidedBy = 'classifier';
661
+ allow = false;
662
+ score = 0;
663
+ }
664
+ }
665
+ }
666
+ let lock;
667
+ if (lockStore) {
668
+ const changes = diffTools(lockEntry?.tools, fetched.tools);
669
+ const drift = findings.some(f => f.code === 'TOOL_DEF_DRIFT' || f.code === 'SERVER_IDENTITY_DRIFT' || f.code === 'PIN_FORMAT_UPGRADE_REQUIRED' || (f.code === 'TOOL_DEF_UNCANONICAL' && !!lockEntry));
670
+ lock = { state: !lockEntry ? 'missing' : drift ? 'drift' : 'match', ...(changes.length && lockEntry ? { changes } : !lockEntry ? { changes } : {}) };
671
+ }
672
+ const classifierBlocked = new Set(opts.classifier?.blocks
673
+ ? [...(classifier?.error ? classifier.incompleteTools ?? fetched.tools.map(t => t.name) : []),
674
+ ...findings.filter(f => f.gate === 'classifier' && f.tool && !f.advisory && blocking(f, opts.policy)).map(f => f.tool)]
675
+ : []);
676
+ return { ...base, status: 'scanned', allow, decidedBy: allow ? undefined : decidedBy, score, findings,
677
+ allowedTools: verdict.allowedTools.filter(t => !classifierBlocked.has(t)), blockedTools: [...new Set([...verdict.blockedTools, ...classifierBlocked])], toolCount: fetched.tools.length,
678
+ serverInfo: fetched.serverInfo, mtlDigest, mtlError, histor, classifier, lock, tools: fetched.tools };
679
+ });
680
+ let lockSummary;
681
+ if (opts.lock && lockStore) {
682
+ const ids = new Set(servers.filter(s => s.status !== 'skipped').map(s => s.id));
683
+ const stale = [...lockStore.entries.keys()].filter(id => !ids.has(id)).sort();
684
+ let written = false;
685
+ if (opts.updateLock) {
686
+ const next = {};
687
+ for (const s of servers) {
688
+ if (s.status === 'skipped')
689
+ continue;
690
+ const previous = lockStore.entries.get(s.id);
691
+ if (s.status !== 'scanned' || !s.tools) {
692
+ if (previous)
693
+ next[s.id] = previous;
694
+ if (s.lock)
695
+ s.lock.state = 'refused';
696
+ continue;
697
+ }
698
+ // Approve only what the non-pinning gates accept, as `pins approve` does.
699
+ const fresh = await Warden.create({ policy: { ...opts.policy, requireApproval: false }, threatFeed: feed, store: NO_PINS, semanticReview: semanticReviews.get(s.tools) }).vet(serverRef(s, all), s.tools);
700
+ const instructionsBlocked = s.findings.some(f => f.tool === 'initialize.instructions' && blocking(f, opts.policy));
701
+ if (!fresh.allow || instructionsBlocked || (opts.classifier?.blocks && (s.classifier?.error || s.findings.some(f => f.gate === 'classifier' && blocking(f, opts.policy))))) {
702
+ if (previous)
703
+ next[s.id] = previous;
704
+ s.lock = { ...(s.lock ?? { state: 'refused' }), state: 'refused' };
705
+ continue;
706
+ }
707
+ const entry = lockEntryFor(serverRef(s, all), s.tools, s.mtlDigest, previous, now);
708
+ next[s.id] = entry;
709
+ const changed = !previous || previous.toolsHash !== entry.toolsHash || previous.identityHash !== entry.identityHash;
710
+ s.lock = { ...(s.lock ?? { state: 'updated' }), state: changed ? 'updated' : 'unchanged' };
711
+ // Once written, the server is reviewed: drop the findings that only said "not reviewed / drifted".
712
+ s.findings = s.findings.filter(f => !['LOCK_MISSING', 'TOOL_DEF_DRIFT', 'SERVER_IDENTITY_DRIFT', 'PIN_FORMAT_UPGRADE_REQUIRED', 'TOOL_DEF_UNPINNED', 'TOOL_DEF_APPROVAL_REQUIRED'].includes(f.code));
713
+ s.allow = !s.findings.some(f => blocking(f, opts.policy));
714
+ if (s.allow) {
715
+ s.decidedBy = undefined;
716
+ s.blockedTools = fresh.blockedTools;
717
+ s.allowedTools = fresh.allowedTools;
718
+ }
719
+ }
720
+ await writeLock(opts.lock, { lockfileVersion: LOCK_VERSION, generatedBy: `@aimarket/warden ${version}`, ruleset: staticScanRulesetRef(), servers: next });
721
+ written = true;
722
+ }
723
+ lockSummary = { path: opts.lock, stale: opts.updateLock ? [] : stale, written };
724
+ }
725
+ const summary = {
726
+ servers: servers.length,
727
+ scanned: servers.filter(s => s.status === 'scanned').length,
728
+ allowed: servers.filter(s => s.allow === true && s.status === 'scanned').length,
729
+ blocked: servers.filter(s => s.allow === false).length,
730
+ errors: servers.filter(s => s.status === 'error').length,
731
+ skipped: servers.filter(s => s.status === 'skipped').length,
732
+ launchOnly: servers.filter(s => s.status === 'launch-only').length,
733
+ };
734
+ return {
735
+ tool: 'warden-scan', version, scannedAt: now, ruleset: staticScanRulesetRef(), policy: opts.policy, launch: opts.launch,
736
+ sources: results.map(r => ({ path: r.source.path, client: r.source.client, scope: r.source.scope, servers: r.servers.length, ...(r.error ? { error: displaySafe(r.error, 300) } : {}) })),
737
+ servers, lock: lockSummary,
738
+ flows: toxicFlows(servers.filter(s => s.status === 'scanned' && s.allow === true).map(s => ({ client: s.client, key: s.key, tools: s.tools?.filter(t => s.allowedTools.includes(t.name)), capabilities: s.tools ? boundCapabilities(serverRef(s, all), s.tools, opts.policy.capabilityBindings) : undefined }))),
739
+ ...historWatchUrl(opts, servers),
740
+ summary,
741
+ };
742
+ }
743
+ function serverRef(s, all) {
744
+ const found = all.find(c => c.id === s.id && c.source === s.source && c.key === s.key);
745
+ return found.ref;
746
+ }
747
+ export function exitCode(report, opts) {
748
+ if (report.summary.blocked > 0)
749
+ return 1;
750
+ if (opts.failOnError && (report.summary.errors > 0 || report.sources.some(s => s.error)))
751
+ return 1;
752
+ return 0;
753
+ }
754
+ //# sourceMappingURL=scan.js.map