llm-checker 3.2.1 → 3.2.3

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.
@@ -4,14 +4,25 @@
4
4
  * Provides smart recommendations based on use case, hardware, and preferences
5
5
  */
6
6
 
7
+ const fs = require('fs');
8
+ const path = require('path');
7
9
  const ScoringEngine = require('./scoring-engine');
8
10
  const UnifiedDetector = require('../hardware/unified-detector');
11
+ const PolicyManager = require('../policy/policy-manager');
12
+ const PolicyEngine = require('../policy/policy-engine');
13
+
14
+ function isPlainObject(value) {
15
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
16
+ }
9
17
 
10
18
  class IntelligentSelector {
11
19
  constructor(options = {}) {
12
20
  this.scoring = new ScoringEngine(options.scoring || {});
13
21
  this.detector = options.detector || new UnifiedDetector();
14
22
  this.database = options.database || null;
23
+ this.policyManager = options.policyManager || new PolicyManager();
24
+ this.policyEngine = options.policyEngine || null;
25
+ this.defaultPolicyFile = options.policyFile || 'policy.yaml';
15
26
 
16
27
  // Default preferences
17
28
  this.defaults = {
@@ -25,6 +36,7 @@ class IntelligentSelector {
25
36
  excludeFamilies: [],
26
37
  includeVision: false,
27
38
  includeEmbeddings: false,
39
+ policyFile: this.defaultPolicyFile,
28
40
  limit: 10
29
41
  };
30
42
  }
@@ -61,25 +73,35 @@ class IntelligentSelector {
61
73
  headroom: opts.headroom || 2
62
74
  });
63
75
 
76
+ const policyEngine = this.resolvePolicyEngine(opts);
77
+ const scoredWithPolicy = policyEngine.evaluateScoredVariants(
78
+ scored,
79
+ this.buildPolicyContext(hardware, opts)
80
+ );
81
+
64
82
  // Categorize scores
65
- const categories = this.scoring.categorizeScores(scored);
83
+ const categories = this.scoring.categorizeScores(scoredWithPolicy);
66
84
 
67
85
  // Get top picks
68
- const topPicks = this.selectTopPicks(scored, opts);
86
+ const topPicks = this.selectTopPicks(scoredWithPolicy, opts);
69
87
 
70
88
  // Generate insights
71
- const insights = this.generateInsights(scored, hardware, opts);
89
+ const insights = this.generateInsights(scoredWithPolicy, hardware, opts);
72
90
 
73
91
  return {
74
92
  topPicks,
75
93
  categories,
76
- all: scored.slice(0, opts.limit),
94
+ all: scoredWithPolicy.slice(0, opts.limit),
77
95
  hardware: {
78
96
  description: this.detector.getHardwareDescription(),
79
97
  tier: this.detector.getHardwareTier(),
80
98
  maxSize: this.detector.getMaxModelSize(),
81
99
  backend: hardware.summary.bestBackend
82
100
  },
101
+ policy: {
102
+ mode: policyEngine.getMode(),
103
+ active: policyEngine.hasActiveRules()
104
+ },
83
105
  insights,
84
106
  meta: {
85
107
  totalCandidates: variants.length,
@@ -89,6 +111,69 @@ class IntelligentSelector {
89
111
  };
90
112
  }
91
113
 
114
+ /**
115
+ * Resolve policy engine from explicit options, in-memory policy, or policy file.
116
+ */
117
+ resolvePolicyEngine(opts = {}) {
118
+ const explicitEngine = opts.policyEngine || this.policyEngine;
119
+ if (
120
+ explicitEngine &&
121
+ typeof explicitEngine.evaluateScoredVariants === 'function' &&
122
+ typeof explicitEngine.getMode === 'function'
123
+ ) {
124
+ return explicitEngine;
125
+ }
126
+
127
+ if (isPlainObject(opts.policy)) {
128
+ return new PolicyEngine(opts.policy);
129
+ }
130
+
131
+ const policyFile = opts.policyFile || this.defaultPolicyFile;
132
+ if (!policyFile) {
133
+ return new PolicyEngine(null);
134
+ }
135
+
136
+ const policyPath = path.isAbsolute(policyFile)
137
+ ? policyFile
138
+ : path.resolve(process.cwd(), policyFile);
139
+
140
+ if (!fs.existsSync(policyPath)) {
141
+ return new PolicyEngine(null);
142
+ }
143
+
144
+ const validation = this.policyManager.validatePolicyFile(policyPath);
145
+ if (!validation.valid) {
146
+ const details = validation.errors
147
+ .map((error) => `${error.path}: ${error.message}`)
148
+ .join('; ');
149
+ throw new Error(`Invalid policy file at ${policyPath}. ${details}`);
150
+ }
151
+
152
+ return new PolicyEngine(validation.policy);
153
+ }
154
+
155
+ /**
156
+ * Build runtime context for policy checks.
157
+ */
158
+ buildPolicyContext(hardware, opts = {}) {
159
+ const summary = hardware?.summary || {};
160
+ const systemRAM = typeof summary.systemRAM === 'number' ? summary.systemRAM : null;
161
+
162
+ const context = {
163
+ backend: summary.bestBackend || null,
164
+ runtimeBackend: summary.bestBackend || null,
165
+ ramGB: systemRAM,
166
+ totalRamGB: systemRAM,
167
+ hardware
168
+ };
169
+
170
+ if (typeof opts.isLocal === 'boolean') {
171
+ context.isLocal = opts.isLocal;
172
+ }
173
+
174
+ return context;
175
+ }
176
+
92
177
  /**
93
178
  * Apply filters to variant list
94
179
  */
@@ -0,0 +1,420 @@
1
+ const crypto = require('crypto');
2
+
3
+ const SEVERITY_BY_CODE = {
4
+ MODEL_DENIED: 'high',
5
+ MODEL_NOT_ALLOWED: 'high',
6
+ BACKEND_NOT_ALLOWED: 'high',
7
+ MODEL_NOT_LOCAL: 'high',
8
+ LICENSE_NOT_APPROVED: 'high',
9
+ LICENSE_MISSING: 'high',
10
+ MODEL_TOO_LARGE: 'medium',
11
+ MODEL_TOO_MANY_PARAMS: 'medium',
12
+ INSUFFICIENT_RAM: 'medium',
13
+ QUANTIZATION_NOT_ALLOWED: 'medium',
14
+ MODEL_SIZE_UNKNOWN: 'low',
15
+ MODEL_PARAMS_UNKNOWN: 'low',
16
+ QUANTIZATION_UNKNOWN: 'low',
17
+ BACKEND_UNKNOWN: 'low',
18
+ RAM_UNKNOWN: 'low'
19
+ };
20
+
21
+ const RECOMMENDATIONS_BY_CODE = {
22
+ MODEL_DENIED: 'Select a model that is not in the deny list or update policy approval scope.',
23
+ MODEL_NOT_ALLOWED: 'Use an allowlisted model identifier/tag or extend rules.models.allow.',
24
+ BACKEND_NOT_ALLOWED: 'Switch to an approved runtime backend or update rules.runtime.required_backends.',
25
+ MODEL_NOT_LOCAL: 'Use a local model/runtime path or disable rules.runtime.local_only.',
26
+ LICENSE_NOT_APPROVED: 'Use a model with an approved license or update rules.compliance.approved_licenses.',
27
+ LICENSE_MISSING: 'Populate license metadata for this model before production use.',
28
+ MODEL_TOO_LARGE: 'Select a smaller model or increase rules.models.max_size_gb.',
29
+ MODEL_TOO_MANY_PARAMS: 'Select a model with fewer parameters or increase rules.models.max_params_b.',
30
+ INSUFFICIENT_RAM: 'Use a smaller model/quantization or increase available system memory.',
31
+ QUANTIZATION_NOT_ALLOWED: 'Switch to an approved quantization in rules.models.allowed_quantizations.',
32
+ MODEL_SIZE_UNKNOWN: 'Add model size metadata (size_gb/size) so policy can evaluate it deterministically.',
33
+ MODEL_PARAMS_UNKNOWN: 'Add parameter metadata (params_b) to model metadata.',
34
+ QUANTIZATION_UNKNOWN: 'Add quantization metadata (quant/quantization) to model metadata.',
35
+ BACKEND_UNKNOWN: 'Provide runtime backend context in policy evaluation inputs.',
36
+ RAM_UNKNOWN: 'Provide system RAM metadata in policy evaluation context.'
37
+ };
38
+
39
+ function normalizeValue(value, fallback = 'unknown') {
40
+ if (value === undefined || value === null) return fallback;
41
+ const text = String(value).trim();
42
+ return text.length > 0 ? text : fallback;
43
+ }
44
+
45
+ function csvValue(value) {
46
+ const text = String(value ?? '');
47
+ if (text.includes(',') || text.includes('"') || text.includes('\n')) {
48
+ return `"${text.replace(/"/g, '""')}"`;
49
+ }
50
+ return text;
51
+ }
52
+
53
+ function deterministicRuleId(code, path) {
54
+ const input = `${normalizeValue(code)}|${normalizeValue(path)}`;
55
+ const digest = crypto.createHash('sha1').update(input).digest('hex').slice(0, 12).toUpperCase();
56
+ return `LLMCHECK-${digest}`;
57
+ }
58
+
59
+ function getSeverityForCode(code) {
60
+ return SEVERITY_BY_CODE[code] || 'medium';
61
+ }
62
+
63
+ function getRecommendationForCode(code) {
64
+ return RECOMMENDATIONS_BY_CODE[code] || 'Review policy rule and model metadata to remediate this violation.';
65
+ }
66
+
67
+ function sarifLevelFromSeverity(severity) {
68
+ switch (severity) {
69
+ case 'critical':
70
+ case 'high':
71
+ return 'error';
72
+ case 'medium':
73
+ return 'warning';
74
+ default:
75
+ return 'note';
76
+ }
77
+ }
78
+
79
+ function toFindingRecord(entry, generatedAt) {
80
+ const violation = entry?.violation || {};
81
+ const code = normalizeValue(violation.code, 'UNKNOWN');
82
+ const rulePath = normalizeValue(violation.path, 'policy');
83
+ const severity = getSeverityForCode(code);
84
+
85
+ return {
86
+ generated_at: generatedAt,
87
+ status: normalizeValue(entry?.status, 'active'),
88
+ model_identifier: normalizeValue(entry?.model_identifier),
89
+ model_name: normalizeValue(entry?.model_name),
90
+ source: normalizeValue(entry?.source),
91
+ registry: normalizeValue(entry?.registry),
92
+ version: normalizeValue(entry?.version),
93
+ license: normalizeValue(entry?.license),
94
+ digest: normalizeValue(entry?.digest),
95
+ violation_code: code,
96
+ rule_path: rulePath,
97
+ rule_id: deterministicRuleId(code, rulePath),
98
+ severity,
99
+ message: normalizeValue(violation.message, 'Policy violation detected.'),
100
+ expected: normalizeValue(violation.expected, ''),
101
+ actual: normalizeValue(violation.actual, ''),
102
+ recommendation: getRecommendationForCode(code),
103
+ exception_model: normalizeValue(entry?.exception?.model, ''),
104
+ exception_reason: normalizeValue(entry?.exception?.reason, ''),
105
+ exception_approver: normalizeValue(entry?.exception?.approver, ''),
106
+ exception_expires_at: normalizeValue(entry?.exception?.expires_at, '')
107
+ };
108
+ }
109
+
110
+ function extractHardwareSummary(hardware = {}) {
111
+ return {
112
+ cpu: normalizeValue(hardware?.cpu?.brand || hardware?.cpu?.model),
113
+ cpu_cores: hardware?.cpu?.cores ?? 'unknown',
114
+ memory_gb: hardware?.memory?.total ?? 'unknown',
115
+ gpu: normalizeValue(hardware?.gpu?.model),
116
+ gpu_vram_gb: hardware?.gpu?.vram ?? 'unknown',
117
+ best_backend: normalizeValue(hardware?.summary?.bestBackend),
118
+ os_platform: normalizeValue(hardware?.os?.platform),
119
+ os_release: normalizeValue(hardware?.os?.release)
120
+ };
121
+ }
122
+
123
+ function buildComplianceReport({
124
+ commandName,
125
+ policyPath,
126
+ policy,
127
+ evaluation,
128
+ enforcement,
129
+ runtimeContext,
130
+ options,
131
+ hardware,
132
+ generatedAt
133
+ }) {
134
+ const timestamp = generatedAt || new Date().toISOString();
135
+ const findings = Array.isArray(evaluation?.findings)
136
+ ? evaluation.findings.map((entry) => toFindingRecord(entry, timestamp))
137
+ : [];
138
+
139
+ const activeCount = findings.filter((entry) => entry.status === 'active').length;
140
+ const suppressedCount = findings.filter((entry) => entry.status === 'suppressed').length;
141
+
142
+ return {
143
+ schema_version: '1.0',
144
+ generated_at: timestamp,
145
+ command: normalizeValue(commandName),
146
+ policy: {
147
+ path: normalizeValue(policyPath),
148
+ org: normalizeValue(policy?.org),
149
+ mode: normalizeValue(policy?.mode),
150
+ on_violation: normalizeValue(policy?.enforcement?.on_violation, 'error'),
151
+ allow_exceptions: policy?.enforcement?.allow_exceptions === true,
152
+ reporting_formats: Array.isArray(policy?.reporting?.formats)
153
+ ? policy.reporting.formats
154
+ : []
155
+ },
156
+ enforcement: {
157
+ should_block: Boolean(enforcement?.shouldBlock),
158
+ exit_code: enforcement?.exitCode ?? 0,
159
+ has_failures: Boolean(enforcement?.hasFailures),
160
+ mode: normalizeValue(enforcement?.mode, normalizeValue(policy?.mode)),
161
+ on_violation: normalizeValue(enforcement?.onViolation, 'error')
162
+ },
163
+ runtime: {
164
+ backend: normalizeValue(runtimeContext?.backend),
165
+ runtime_backend: normalizeValue(runtimeContext?.runtimeBackend),
166
+ ram_gb: runtimeContext?.ramGB ?? 'unknown'
167
+ },
168
+ hardware: extractHardwareSummary(hardware),
169
+ options: options || {},
170
+ summary: {
171
+ total_checked: evaluation?.totalChecked ?? 0,
172
+ pass_count: evaluation?.passCount ?? 0,
173
+ fail_count: evaluation?.failCount ?? 0,
174
+ active_violations: activeCount,
175
+ suppressed_violations: suppressedCount,
176
+ exceptions_applied: evaluation?.exceptionsAppliedCount ?? 0,
177
+ top_violations: Array.isArray(evaluation?.topViolations) ? evaluation.topViolations : []
178
+ },
179
+ findings
180
+ };
181
+ }
182
+
183
+ function reportToJson(report) {
184
+ return JSON.stringify(report, null, 2);
185
+ }
186
+
187
+ function reportToCsv(report) {
188
+ const headers = [
189
+ 'generated_at',
190
+ 'command',
191
+ 'policy_path',
192
+ 'policy_mode',
193
+ 'on_violation',
194
+ 'total_checked',
195
+ 'pass_count',
196
+ 'fail_count',
197
+ 'status',
198
+ 'model_identifier',
199
+ 'model_name',
200
+ 'source',
201
+ 'registry',
202
+ 'version',
203
+ 'license',
204
+ 'digest',
205
+ 'violation_code',
206
+ 'rule_path',
207
+ 'rule_id',
208
+ 'severity',
209
+ 'message',
210
+ 'expected',
211
+ 'actual',
212
+ 'recommendation',
213
+ 'exception_model',
214
+ 'exception_reason',
215
+ 'exception_approver',
216
+ 'exception_expires_at'
217
+ ];
218
+
219
+ const rows = [headers.join(',')];
220
+
221
+ if (!Array.isArray(report.findings) || report.findings.length === 0) {
222
+ rows.push(
223
+ [
224
+ report.generated_at,
225
+ report.command,
226
+ report.policy?.path,
227
+ report.policy?.mode,
228
+ report.policy?.on_violation,
229
+ report.summary?.total_checked,
230
+ report.summary?.pass_count,
231
+ report.summary?.fail_count,
232
+ 'compliant',
233
+ '',
234
+ '',
235
+ '',
236
+ '',
237
+ '',
238
+ '',
239
+ '',
240
+ '',
241
+ '',
242
+ '',
243
+ '',
244
+ 'No policy violations detected.',
245
+ '',
246
+ '',
247
+ '',
248
+ '',
249
+ '',
250
+ '',
251
+ ''
252
+ ]
253
+ .map(csvValue)
254
+ .join(',')
255
+ );
256
+
257
+ return rows.join('\n');
258
+ }
259
+
260
+ report.findings.forEach((finding) => {
261
+ rows.push(
262
+ [
263
+ finding.generated_at,
264
+ report.command,
265
+ report.policy?.path,
266
+ report.policy?.mode,
267
+ report.policy?.on_violation,
268
+ report.summary?.total_checked,
269
+ report.summary?.pass_count,
270
+ report.summary?.fail_count,
271
+ finding.status,
272
+ finding.model_identifier,
273
+ finding.model_name,
274
+ finding.source,
275
+ finding.registry,
276
+ finding.version,
277
+ finding.license,
278
+ finding.digest,
279
+ finding.violation_code,
280
+ finding.rule_path,
281
+ finding.rule_id,
282
+ finding.severity,
283
+ finding.message,
284
+ finding.expected,
285
+ finding.actual,
286
+ finding.recommendation,
287
+ finding.exception_model,
288
+ finding.exception_reason,
289
+ finding.exception_approver,
290
+ finding.exception_expires_at
291
+ ]
292
+ .map(csvValue)
293
+ .join(',')
294
+ );
295
+ });
296
+
297
+ return rows.join('\n');
298
+ }
299
+
300
+ function reportToSarif(report) {
301
+ const findings = Array.isArray(report.findings) ? report.findings : [];
302
+ const rulesMap = new Map();
303
+
304
+ findings.forEach((finding) => {
305
+ if (!rulesMap.has(finding.rule_id)) {
306
+ rulesMap.set(finding.rule_id, {
307
+ id: finding.rule_id,
308
+ name: finding.violation_code,
309
+ shortDescription: {
310
+ text: finding.violation_code
311
+ },
312
+ fullDescription: {
313
+ text: finding.message
314
+ },
315
+ help: {
316
+ text: finding.recommendation
317
+ },
318
+ properties: {
319
+ severity: finding.severity,
320
+ rulePath: finding.rule_path,
321
+ code: finding.violation_code
322
+ }
323
+ });
324
+ }
325
+ });
326
+
327
+ const results = findings.map((finding) => {
328
+ const level = finding.status === 'suppressed' ? 'note' : sarifLevelFromSeverity(finding.severity);
329
+ const messagePrefix = finding.status === 'suppressed' ? '[suppressed by exception] ' : '';
330
+
331
+ return {
332
+ ruleId: finding.rule_id,
333
+ level,
334
+ message: {
335
+ text: `${messagePrefix}${finding.message}`
336
+ },
337
+ locations: [
338
+ {
339
+ physicalLocation: {
340
+ artifactLocation: {
341
+ uri: finding.model_identifier
342
+ },
343
+ region: {
344
+ startLine: 1
345
+ }
346
+ }
347
+ }
348
+ ],
349
+ properties: {
350
+ modelName: finding.model_name,
351
+ modelSource: finding.source,
352
+ policyPath: finding.rule_path,
353
+ expected: finding.expected,
354
+ actual: finding.actual,
355
+ recommendation: finding.recommendation,
356
+ status: finding.status,
357
+ exceptionReason: finding.exception_reason
358
+ },
359
+ partialFingerprints: {
360
+ ruleFingerprint: finding.rule_id,
361
+ modelFingerprint: `${finding.model_identifier}:${finding.violation_code}`
362
+ }
363
+ };
364
+ });
365
+
366
+ const sarif = {
367
+ version: '2.1.0',
368
+ $schema:
369
+ 'https://json.schemastore.org/sarif-2.1.0.json',
370
+ runs: [
371
+ {
372
+ tool: {
373
+ driver: {
374
+ name: 'llm-checker-policy',
375
+ version: '1.0.0',
376
+ informationUri: 'https://github.com/Pavelevich/llm-checker',
377
+ rules: Array.from(rulesMap.values())
378
+ }
379
+ },
380
+ invocations: [
381
+ {
382
+ executionSuccessful: true,
383
+ commandLine: `llm-checker ${report.command}`,
384
+ startTimeUtc: report.generated_at
385
+ }
386
+ ],
387
+ results,
388
+ properties: {
389
+ policyPath: report.policy?.path,
390
+ policyMode: report.policy?.mode,
391
+ totalChecked: report.summary?.total_checked,
392
+ passCount: report.summary?.pass_count,
393
+ failCount: report.summary?.fail_count,
394
+ activeViolations: report.summary?.active_violations,
395
+ suppressedViolations: report.summary?.suppressed_violations
396
+ }
397
+ }
398
+ ]
399
+ };
400
+
401
+ return JSON.stringify(sarif, null, 2);
402
+ }
403
+
404
+ function serializeComplianceReport(report, format = 'json') {
405
+ const normalized = normalizeValue(format, 'json').toLowerCase();
406
+
407
+ if (normalized === 'json') return reportToJson(report);
408
+ if (normalized === 'csv') return reportToCsv(report);
409
+ if (normalized === 'sarif') return reportToSarif(report);
410
+
411
+ throw new Error(`Unsupported report format: ${format}`);
412
+ }
413
+
414
+ module.exports = {
415
+ buildComplianceReport,
416
+ serializeComplianceReport,
417
+ deterministicRuleId,
418
+ getSeverityForCode,
419
+ getRecommendationForCode
420
+ };