@aimarket/warden 0.9.0 → 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 (83) hide show
  1. package/CHANGELOG.md +36 -1
  2. package/README-es.md +36 -11
  3. package/README-fr.md +36 -11
  4. package/README-ru.md +38 -11
  5. package/README-zh.md +36 -11
  6. package/README.md +38 -11
  7. package/dist/classifier.d.ts +39 -10
  8. package/dist/classifier.d.ts.map +1 -1
  9. package/dist/classifier.js +241 -45
  10. package/dist/classifier.js.map +1 -1
  11. package/dist/encoded-text.d.ts +7 -0
  12. package/dist/encoded-text.d.ts.map +1 -0
  13. package/dist/encoded-text.js +63 -0
  14. package/dist/encoded-text.js.map +1 -0
  15. package/dist/fold.d.ts +1 -2
  16. package/dist/fold.d.ts.map +1 -1
  17. package/dist/fold.js +3 -2
  18. package/dist/fold.js.map +1 -1
  19. package/dist/histor.d.ts +39 -4
  20. package/dist/histor.d.ts.map +1 -1
  21. package/dist/histor.js +11 -7
  22. package/dist/histor.js.map +1 -1
  23. package/dist/index.d.ts +6 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +8 -4
  26. package/dist/index.js.map +1 -1
  27. package/dist/mcp-tools.d.ts.map +1 -1
  28. package/dist/mcp-tools.js +29 -4
  29. package/dist/mcp-tools.js.map +1 -1
  30. package/dist/pin-store.d.ts.map +1 -1
  31. package/dist/pin-store.js +1 -0
  32. package/dist/pin-store.js.map +1 -1
  33. package/dist/pinning.d.ts +1 -1
  34. package/dist/pinning.d.ts.map +1 -1
  35. package/dist/pinning.js +12 -4
  36. package/dist/pinning.js.map +1 -1
  37. package/dist/result-screen.d.ts +19 -0
  38. package/dist/result-screen.d.ts.map +1 -0
  39. package/dist/result-screen.js +86 -0
  40. package/dist/result-screen.js.map +1 -0
  41. package/dist/scan-lock.d.ts.map +1 -1
  42. package/dist/scan-lock.js +2 -1
  43. package/dist/scan-lock.js.map +1 -1
  44. package/dist/scan-report.d.ts.map +1 -1
  45. package/dist/scan-report.js +15 -0
  46. package/dist/scan-report.js.map +1 -1
  47. package/dist/scan.d.ts +40 -0
  48. package/dist/scan.d.ts.map +1 -1
  49. package/dist/scan.js +245 -25
  50. package/dist/scan.js.map +1 -1
  51. package/dist/static-scan.d.ts +4 -3
  52. package/dist/static-scan.d.ts.map +1 -1
  53. package/dist/static-scan.js +40 -9
  54. package/dist/static-scan.js.map +1 -1
  55. package/dist/toxic-flow.d.ts +33 -0
  56. package/dist/toxic-flow.d.ts.map +1 -0
  57. package/dist/toxic-flow.js +117 -0
  58. package/dist/toxic-flow.js.map +1 -0
  59. package/dist/types.d.ts +12 -0
  60. package/dist/types.d.ts.map +1 -1
  61. package/dist/wrap-cli.d.ts +3 -0
  62. package/dist/wrap-cli.d.ts.map +1 -1
  63. package/dist/wrap-cli.js +17 -6
  64. package/dist/wrap-cli.js.map +1 -1
  65. package/dist/wrap-state.js +2 -2
  66. package/dist/wrap-state.js.map +1 -1
  67. package/dist/wrap.d.ts.map +1 -1
  68. package/dist/wrap.js +20 -2
  69. package/dist/wrap.js.map +1 -1
  70. package/docs/scan.es.md +29 -14
  71. package/docs/scan.fr.md +29 -14
  72. package/docs/scan.md +29 -14
  73. package/docs/scan.ru.md +29 -14
  74. package/docs/scan.zh.md +29 -14
  75. package/docs/scanner-comparison.es.md +3 -12
  76. package/docs/scanner-comparison.fr.md +3 -13
  77. package/docs/scanner-comparison.md +3 -12
  78. package/docs/scanner-comparison.ru.md +3 -12
  79. package/docs/scanner-comparison.zh.md +3 -7
  80. package/docs/security-hardening-language-boundary.md +306 -0
  81. package/package.json +6 -3
  82. package/scripts/check-quality-gate.mjs +64 -0
  83. package/scripts/quality-public-key.pem +3 -0
package/dist/scan.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { type ClientKind, type ConfiguredServer, type DiscoveryEnv } from './scan-config.js';
2
2
  import { type FetchedServer } from './mcp-client.js';
3
+ import { type HistorCheck } from './histor.js';
4
+ import { type ToxicFlow } from './toxic-flow.js';
3
5
  import { type ClassifierOptions } from './classifier.js';
4
6
  import { type ToolChange } from './scan-lock.js';
5
7
  import type { Severity, ToolDef, WardenFinding, WardenPolicy, RulesetRef } from './types.js';
@@ -34,6 +36,12 @@ export interface ScanOptions {
34
36
  }
35
37
  export type ServerStatus = 'scanned' | 'launch-only' | 'blocked-at-launch' | 'error' | 'skipped';
36
38
  export interface HistorSummary {
39
+ /** What was asked about: an endpoint, or a stdio server's `npm:`/`pypi:` package. */
40
+ package?: string;
41
+ /** The package version HISTOR last observed in its sandbox. */
42
+ packageVersion?: string;
43
+ /** HISTOR's id for the server: what its feed takes in `watch=`. */
44
+ targetId?: string;
37
45
  match?: string;
38
46
  note?: string;
39
47
  page?: string;
@@ -73,6 +81,8 @@ export interface ScanServerResult {
73
81
  model: string;
74
82
  flagged: number;
75
83
  error?: string;
84
+ incompleteTools?: string[];
85
+ retried?: number;
76
86
  };
77
87
  lock?: {
78
88
  state: 'match' | 'drift' | 'missing' | 'updated' | 'unchanged' | 'refused';
@@ -101,6 +111,13 @@ export interface ScanReport {
101
111
  stale: string[];
102
112
  written: boolean;
103
113
  };
114
+ /**
115
+ * Per client: private data, outside text and an outbound channel among the servers it starts
116
+ * (toxic-flow.ts). Advisory: a flow never blocks and never changes the exit code.
117
+ */
118
+ flows: ToxicFlow[];
119
+ /** With --histor: a feed of changes to the servers HISTOR knows among these, for any feed reader. */
120
+ historWatch?: string;
104
121
  summary: {
105
122
  servers: number;
106
123
  scanned: number;
@@ -126,6 +143,29 @@ export declare function historEndpoint(raw: string): {
126
143
  endpoint?: string;
127
144
  reason?: string;
128
145
  };
146
+ /**
147
+ * The npm or PyPI package a stdio launch runs, as HISTOR names it (`npm:<name>`,
148
+ * `pypi:<normalised name>`), or why it is not sent. Only a package from the public
149
+ * registry is named: a local path, a git or tarball URL, or a private registry
150
+ * (`--registry`, `--index-url`) stays on this machine. The version is not sent —
151
+ * the digest already tells HISTOR which of the versions it observed you run.
152
+ */
153
+ export declare function historPackage(command: string, args: readonly string[]): {
154
+ package?: string;
155
+ reason?: string;
156
+ };
157
+ /**
158
+ * What HISTOR knows about a package beyond its tools: a name made to be taken for a popular one,
159
+ * and the marks of a stolen publishing token on the version it observed. All advisory.
160
+ */
161
+ export declare function packageFindings(answer: HistorCheck): WardenFinding[];
162
+ /**
163
+ * What the package did in HISTOR's sandbox (gVisor's own trace, decoy credentials, canary tool
164
+ * calls). Reading a decoy or writing where it would persist is the loud part; contacting its own
165
+ * API during a call is what API servers do and is not a finding, nor is loading .env from the
166
+ * working directory, which dotenv does for half the ecosystem.
167
+ */
168
+ export declare function behaviourFindings(behaviour: Record<string, unknown> | undefined, version: string): WardenFinding[];
129
169
  export interface ScanDeps {
130
170
  fetch?: (server: ConfiguredServer, opts: ScanOptions) => Promise<FetchedServer>;
131
171
  historFetch?: typeof fetch;
@@ -1 +1 @@
1
- {"version":3,"file":"scan.d.ts","sourceRoot":"","sources":["../src/scan.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"scan.d.ts","sourceRoot":"","sources":["../src/scan.ts"],"names":[],"mappings":"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"}
package/dist/scan.js CHANGED
@@ -9,7 +9,8 @@ import { discoverServers, CLIENT_KINDS } from './scan-config.js';
9
9
  import { fetchServer, McpClientError } from './mcp-client.js';
10
10
  import { mtlToolSetDigest, MtlError } from './mtl.js';
11
11
  import { historCheck, DEFAULT_HISTOR_URL } from './histor.js';
12
- import { classifyTools, ClassifierError } from './classifier.js';
12
+ import { boundCapabilities, toxicFlows } from './toxic-flow.js';
13
+ import { inspectTools, ClassifierError } from './classifier.js';
13
14
  import { LockStore, readLock, writeLock, lockEntryFor, diffTools, LOCK_VERSION } from './scan-lock.js';
14
15
  export const SEVERITIES = ['info', 'low', 'medium', 'high', 'critical'];
15
16
  // ── argument parsing ─────────────────────────────────────────────────────────
@@ -35,18 +36,22 @@ How to connect
35
36
  Verdicts
36
37
  --fail-on SEVERITY block at this severity: info|low|medium|high|critical (default high)
37
38
  --policy FILE strict JSON policy, as for wrap
39
+ --require-approval require an explicit operator pin/lock covering every field and identity
38
40
  --lock FILE compare with reviewed definitions; a server missing from the lock is blocked
39
41
  --update-lock write what the servers advertise now into --lock (refuses blocked servers)
40
42
  --state-dir DIR where wrap keeps pins (read only here)
41
43
  --feed URL --feed-key HEX signed threat feed, as for wrap
42
- --histor ask the HISTOR log whether remote servers serve you what they serve everyone
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)
43
46
  (sends the endpoint without query or credentials, and a digest; never tool text)
44
47
  --histor-url URL default ${DEFAULT_HISTOR_URL}
45
48
  --classifier-url URL --classifier-model NAME
46
49
  also ask a model you choose (OpenAI-compatible: Ollama, vLLM, a hosted API)
47
50
  whether each tool's text directs the model. Sends tool definitions to that URL.
48
51
  Key, if any, from WARDEN_CLASSIFIER_API_KEY. Advisory unless --classifier-blocks.
49
- --classifier-blocks let the classifier's high/medium verdicts block like a rule
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
50
55
 
51
56
  Output
52
57
  --json JSON report on stdout instead of a table
@@ -62,7 +67,9 @@ export async function parseScanArgs(argv, env = process.env) {
62
67
  format: 'table', failOnError: false, color: !!process.stdout.isTTY && !env.NO_COLOR,
63
68
  };
64
69
  let failOn, policyFile;
70
+ let requireApproval = false;
65
71
  let classifierUrl, classifierModel, classifierBlocks = false;
72
+ let classifierReasoning;
66
73
  const clients = [];
67
74
  const value = (flag) => {
68
75
  const v = argv.shift();
@@ -148,6 +155,12 @@ export async function parseScanArgs(argv, env = process.env) {
148
155
  case '--classifier-model':
149
156
  classifierModel = value(arg);
150
157
  break;
158
+ case '--classifier-reasoning-effort':
159
+ classifierReasoning = value(arg);
160
+ break;
161
+ case '--require-approval':
162
+ requireApproval = true;
163
+ break;
151
164
  case '--classifier-blocks':
152
165
  classifierBlocks = true;
153
166
  break;
@@ -188,23 +201,28 @@ export async function parseScanArgs(argv, env = process.env) {
188
201
  if (!raw || typeof raw !== 'object' || Array.isArray(raw))
189
202
  throw new Error('policy must be an object');
190
203
  for (const key of Object.keys(raw))
191
- if (!['blockAtSeverity', 'pinToolDefs', 'allowUnknownServers', 'sensitiveToolPatterns'].includes(key))
204
+ if (!['blockAtSeverity', 'pinToolDefs', 'allowUnknownServers', 'sensitiveToolPatterns', 'requireApproval', 'capabilityBindings'].includes(key))
192
205
  throw new Error(`Unknown policy field ${key}`);
193
206
  opts.policy = asPolicy(raw);
194
207
  }
208
+ if (requireApproval)
209
+ opts.policy.requireApproval = true;
195
210
  if (failOn)
196
211
  opts.policy = { ...opts.policy, blockAtSeverity: failOn };
197
212
  if (!!classifierUrl !== !!classifierModel)
198
213
  throw new Error('--classifier-url and --classifier-model go together');
199
214
  if (classifierBlocks && !classifierUrl)
200
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');
201
218
  if (classifierUrl && classifierModel) {
202
219
  const cu = new URL(classifierUrl);
203
220
  if (cu.protocol !== 'https:' && cu.protocol !== 'http:')
204
221
  throw new Error('--classifier-url must be http(s)');
205
222
  if (cu.username || cu.password)
206
223
  throw new Error('--classifier-url must not carry credentials; use WARDEN_CLASSIFIER_API_KEY');
207
- opts.classifier = { url: classifierUrl, model: classifierModel, apiKey: env.WARDEN_CLASSIFIER_API_KEY || undefined, blocks: classifierBlocks };
224
+ opts.classifier = { url: classifierUrl, model: classifierModel, apiKey: env.WARDEN_CLASSIFIER_API_KEY || undefined, blocks: classifierBlocks,
225
+ ...(classifierReasoning ? { reasoningEffort: classifierReasoning } : {}) };
208
226
  }
209
227
  const u = new URL(opts.historUrl);
210
228
  if (u.protocol !== 'https:' && u.protocol !== 'http:')
@@ -286,6 +304,149 @@ export function historEndpoint(raw) {
286
304
  return { reason: 'the path looks like it carries a credential' };
287
305
  return { endpoint: `${u.protocol}//${u.host}${u.pathname}` };
288
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
+ }
289
450
  // ── the scan ─────────────────────────────────────────────────────────────────
290
451
  async function pool(items, n, fn) {
291
452
  const out = new Array(items.length);
@@ -329,6 +490,7 @@ export async function runScan(opts, deps = {}) {
329
490
  const lockStore = opts.lock ? new LockStore(lockFile?.servers ?? {}) : undefined;
330
491
  const wrapPins = READ_ONLY(new FilePinStore(join(opts.stateDir, 'pins')));
331
492
  const now = (deps.now ?? (() => new Date()))().toISOString();
493
+ const semanticReviews = new Map();
332
494
  const all = results.flatMap(r => r.servers);
333
495
  // The same server listed by two clients is checked once per entry: they are separate launches.
334
496
  const servers = await pool(all, opts.concurrency, async (server) => {
@@ -353,8 +515,11 @@ export async function runScan(opts, deps = {}) {
353
515
  }
354
516
  if (server.ref.transport === 'stdio' && !opts.launch) {
355
517
  const findings = [...launchVerdict.findings, ...(lockMissing ? [lockMissing] : [])];
356
- return { ...base, status: 'launch-only', allow: !lockMissing, score: launchVerdict.score, findings,
357
- ...(lockMissing ? { decidedBy: 'lock' } : {}), lock: lockStore ? { state: lockEntry ? 'match' : 'missing' } : undefined };
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 };
358
523
  }
359
524
  let fetched;
360
525
  try {
@@ -365,7 +530,20 @@ export async function runScan(opts, deps = {}) {
365
530
  const msg = err instanceof McpClientError ? err.message : err instanceof Error ? err.message : String(err);
366
531
  return { ...base, status: 'error', error: displaySafe(msg, 400), findings: lockMissing ? [lockMissing] : [], allow: lockMissing ? false : null, ...(lockMissing ? { decidedBy: 'lock' } : {}) };
367
532
  }
368
- const verdict = await warden.vet(server.ref, fetched.tools);
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);
369
547
  const findings = [...verdict.findings];
370
548
  let allow = verdict.allow, decidedBy = verdict.decidedBy, score = verdict.score;
371
549
  if (fetched.instructions) {
@@ -395,21 +573,33 @@ export async function runScan(opts, deps = {}) {
395
573
  mtlError = err instanceof MtlError ? err.code : String(err);
396
574
  }
397
575
  let histor;
398
- if (opts.histor && server.ref.url) {
399
- const target = historEndpoint(server.ref.url);
400
- if (!target.endpoint)
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)
401
581
  histor = { notSent: target.reason };
402
582
  else {
403
583
  try {
404
- const answer = await historCheck(opts.historUrl, { endpoint: target.endpoint, ...(mtlDigest ? { toolSetDigest: mtlDigest } : {}) }, { fetchImpl: deps.historFetch, userAgent: `warden-scan/${version}` });
405
- histor = { match: String(answer.match), note: typeof answer.note === 'string' ? displaySafe(answer.note, 400) : undefined,
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,
406
590
  page: answer.target?.page, unchangedSince: answer.observed?.unchangedSince, changes: answer.observed?.changes };
407
591
  if (answer.match === 'different')
408
592
  findings.push({ gate: 'histor', severity: 'medium', code: 'HISTOR_UNSEEN_TOOLSET', advisory: true,
409
- message: `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.` });
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));
410
598
  if (answer.match === 'previously-observed')
411
599
  findings.push({ gate: 'histor', severity: 'low', code: 'HISTOR_OLDER_TOOLSET', advisory: true,
412
- message: 'HISTOR observed this tool set earlier, but the server now serves the public a different one.' });
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.' });
413
603
  // The log's own classifier verdict on the set you were served, when it holds one: advisory, never a block.
414
604
  const cls = answer.classifier;
415
605
  if (answer.match === 'same' && cls && cls.status === 'classified' && Array.isArray(cls.findings)) {
@@ -430,11 +620,26 @@ export async function runScan(opts, deps = {}) {
430
620
  let classifier;
431
621
  if (opts.classifier && fetched.tools.length) {
432
622
  try {
433
- const verdicts = await classifyTools(fetched.tools, opts.classifier);
434
- classifier = { model: opts.classifier.model, flagged: verdicts.length };
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
+ }
435
640
  for (const v of verdicts) {
436
641
  const f = { gate: 'classifier', code: 'TOOL_DEF_CLASSIFIER', severity: v.severity, tool: v.tool,
437
- message: `Model ${displaySafe(opts.classifier.model, 60)} reads this tool as ${v.categories.join(', ')}: ${displaySafe(v.reason, 300)} — at "${displaySafe(v.quote, 200)}"` };
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)}"` };
438
643
  if (!opts.classifier.blocks)
439
644
  f.advisory = true;
440
645
  findings.push(f);
@@ -447,6 +652,15 @@ export async function runScan(opts, deps = {}) {
447
652
  }
448
653
  catch (err) {
449
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
+ }
450
664
  }
451
665
  }
452
666
  let lock;
@@ -455,10 +669,13 @@ export async function runScan(opts, deps = {}) {
455
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));
456
670
  lock = { state: !lockEntry ? 'missing' : drift ? 'drift' : 'match', ...(changes.length && lockEntry ? { changes } : !lockEntry ? { changes } : {}) };
457
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
+ : []);
458
676
  return { ...base, status: 'scanned', allow, decidedBy: allow ? undefined : decidedBy, score, findings,
459
- allowedTools: verdict.allowedTools, blockedTools: verdict.blockedTools, toolCount: fetched.tools.length,
460
- serverInfo: fetched.serverInfo, mtlDigest, mtlError, histor, classifier, lock, tools: fetched.tools,
461
- ...(classifier && opts.classifier?.blocks ? { blockedTools: [...new Set([...verdict.blockedTools, ...findings.filter(f => f.gate === 'classifier' && !f.advisory && blocking(f, opts.policy)).map(f => f.tool)])] } : {}) };
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 };
462
679
  });
463
680
  let lockSummary;
464
681
  if (opts.lock && lockStore) {
@@ -479,9 +696,9 @@ export async function runScan(opts, deps = {}) {
479
696
  continue;
480
697
  }
481
698
  // Approve only what the non-pinning gates accept, as `pins approve` does.
482
- const fresh = await Warden.create({ policy: opts.policy, threatFeed: feed, store: NO_PINS }).vet(serverRef(s, all), s.tools);
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);
483
700
  const instructionsBlocked = s.findings.some(f => f.tool === 'initialize.instructions' && blocking(f, opts.policy));
484
- if (!fresh.allow || instructionsBlocked) {
701
+ if (!fresh.allow || instructionsBlocked || (opts.classifier?.blocks && (s.classifier?.error || s.findings.some(f => f.gate === 'classifier' && blocking(f, opts.policy))))) {
485
702
  if (previous)
486
703
  next[s.id] = previous;
487
704
  s.lock = { ...(s.lock ?? { state: 'refused' }), state: 'refused' };
@@ -492,7 +709,7 @@ export async function runScan(opts, deps = {}) {
492
709
  const changed = !previous || previous.toolsHash !== entry.toolsHash || previous.identityHash !== entry.identityHash;
493
710
  s.lock = { ...(s.lock ?? { state: 'updated' }), state: changed ? 'updated' : 'unchanged' };
494
711
  // Once written, the server is reviewed: drop the findings that only said "not reviewed / drifted".
495
- s.findings = s.findings.filter(f => !['LOCK_MISSING', 'TOOL_DEF_DRIFT', 'SERVER_IDENTITY_DRIFT', 'PIN_FORMAT_UPGRADE_REQUIRED', 'TOOL_DEF_UNPINNED'].includes(f.code));
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));
496
713
  s.allow = !s.findings.some(f => blocking(f, opts.policy));
497
714
  if (s.allow) {
498
715
  s.decidedBy = undefined;
@@ -517,7 +734,10 @@ export async function runScan(opts, deps = {}) {
517
734
  return {
518
735
  tool: 'warden-scan', version, scannedAt: now, ruleset: staticScanRulesetRef(), policy: opts.policy, launch: opts.launch,
519
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) } : {}) })),
520
- servers, lock: lockSummary, summary,
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,
521
741
  };
522
742
  }
523
743
  function serverRef(s, all) {