@rigour-labs/core 5.2.8 → 5.3.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 (38) hide show
  1. package/dist/context/cache-engine.d.ts +75 -0
  2. package/dist/context/cache-engine.js +155 -0
  3. package/dist/context/cache-engine.test.d.ts +1 -0
  4. package/dist/context/cache-engine.test.js +107 -0
  5. package/dist/context/context-session.d.ts +25 -0
  6. package/dist/context/context-session.js +78 -0
  7. package/dist/context/index-bridge.d.ts +24 -0
  8. package/dist/context/index-bridge.js +114 -0
  9. package/dist/hooks/dlp-confidence.d.ts +16 -0
  10. package/dist/hooks/dlp-confidence.js +302 -0
  11. package/dist/hooks/dlp-confidence.test.d.ts +1 -0
  12. package/dist/hooks/dlp-confidence.test.js +51 -0
  13. package/dist/hooks/dlp-feedback.d.ts +48 -0
  14. package/dist/hooks/dlp-feedback.js +174 -0
  15. package/dist/hooks/dlp-feedback.test.d.ts +1 -0
  16. package/dist/hooks/dlp-feedback.test.js +39 -0
  17. package/dist/hooks/index.d.ts +2 -0
  18. package/dist/hooks/index.js +1 -0
  19. package/dist/hooks/input-validator.d.ts +8 -0
  20. package/dist/hooks/input-validator.js +131 -12
  21. package/dist/hooks/input-validator.test.js +92 -34
  22. package/dist/index.d.ts +6 -1
  23. package/dist/index.js +7 -1
  24. package/dist/services/context-telemetry-service.d.ts +121 -0
  25. package/dist/services/context-telemetry-service.js +375 -0
  26. package/dist/services/context-telemetry-service.test.d.ts +1 -0
  27. package/dist/services/context-telemetry-service.test.js +130 -0
  28. package/dist/settings.d.ts +11 -0
  29. package/dist/settings.js +18 -0
  30. package/dist/storage/context-telemetry.d.ts +83 -0
  31. package/dist/storage/context-telemetry.js +313 -0
  32. package/dist/storage/context-telemetry.test.d.ts +1 -0
  33. package/dist/storage/context-telemetry.test.js +80 -0
  34. package/dist/storage/db.js +140 -2
  35. package/dist/storage/index.d.ts +2 -0
  36. package/dist/storage/index.js +1 -0
  37. package/package.json +82 -82
  38. package/LICENSE +0 -21
@@ -16,6 +16,8 @@
16
16
  * - Azure / GCP / Stripe / Twilio keys
17
17
  *
18
18
  */
19
+ import { classifyDLPDetection } from './dlp-confidence.js';
20
+ import { isLearnedAllowSync, loadDLPFeedbackStoreSync } from './dlp-feedback.js';
19
21
  const CREDENTIAL_PATTERNS = [
20
22
  // ── Cloud Provider Keys ───────────────────────────────────
21
23
  {
@@ -53,7 +55,7 @@ const CREDENTIAL_PATTERNS = [
53
55
  // ── API Keys (Provider-Specific Prefixes) ─────────────────
54
56
  {
55
57
  type: 'openai_key',
56
- regex: /\b(sk-(?:proj-)?[A-Za-z0-9]{20,})\b/g,
58
+ regex: /\b(sk-(?!ant-)(?:proj-)?[A-Za-z0-9_-]{24,})\b/g,
57
59
  severity: 'critical',
58
60
  description: 'OpenAI API key detected',
59
61
  recommendation: 'Use process.env.OPENAI_API_KEY instead',
@@ -85,7 +87,7 @@ const CREDENTIAL_PATTERNS = [
85
87
  },
86
88
  {
87
89
  type: 'twilio_key',
88
- regex: /\b(SK[a-f0-9]{32})\b/g,
90
+ regex: /(?:TWILIO[_-]?(?:API[_-]?)?KEY|twilio[_-]?api[_-]?key)\s*[:=]\s*['"]?(SK[a-f0-9]{32})['"]?/gi,
89
91
  severity: 'high',
90
92
  description: 'Twilio API key detected',
91
93
  recommendation: 'Use process.env.TWILIO_API_KEY instead',
@@ -119,7 +121,7 @@ const CREDENTIAL_PATTERNS = [
119
121
  // ── Database Connection Strings ───────────────────────────
120
122
  {
121
123
  type: 'database_url',
122
- regex: /\b((?:postgres(?:ql)?|mysql|mariadb|mssql|mongodb(?:\+srv)?|redis|rediss|amqp|amqps):\/\/[^\s'"`,;}{)]+)/gi,
124
+ regex: /\b((?:postgres(?:ql)?|mysql|mariadb|mssql|mongodb(?:\+srv)?|redis|rediss|amqp|amqps):\/\/[^/\s@]+:[^@/\s]+@[^\s'"`,;}{)]+)/gi,
123
125
  severity: 'critical',
124
126
  description: 'Database connection string with credentials detected',
125
127
  recommendation: 'Use process.env.DATABASE_URL instead',
@@ -243,6 +245,31 @@ function shannonEntropy(str) {
243
245
  return sum - p * Math.log2(p);
244
246
  }, 0);
245
247
  }
248
+ /** Credential-less local DB URLs (no user:pass@) are safe for dev discussion. */
249
+ function isCredentiallessLocalUrl(url) {
250
+ if (/:[^/@\s]+@[^/\s]+/i.test(url))
251
+ return false;
252
+ return /:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::|\/|$)/i.test(url);
253
+ }
254
+ function isPlaceholderEnvValue(value) {
255
+ return /^(?:your[_-]?)?(?:api[_-]?)?key[_-]?here$/i.test(value)
256
+ || /^your[_-]/i.test(value)
257
+ || /^replace[_-]?me$/i.test(value)
258
+ || /^changeme$/i.test(value)
259
+ || /^not-a-real/i.test(value)
260
+ || /^super-secret-client-id-string$/i.test(value)
261
+ || /^x{8,}$/i.test(value);
262
+ }
263
+ function looksLikeLowEntropyOpenAiKey(match) {
264
+ const suffix = match.replace(/^sk-(?:proj-)?/i, '');
265
+ if (suffix.length < 24)
266
+ return true;
267
+ if (/^(.)\1{5,}/.test(suffix))
268
+ return true;
269
+ if (shannonEntropy(suffix) < 3.8)
270
+ return true;
271
+ return false;
272
+ }
246
273
  // ── Core Scanner ──────────────────────────────────────────────────
247
274
  /**
248
275
  * Redact a matched credential for safe display.
@@ -286,6 +313,18 @@ export function scanInputForCredentials(input, config = {}) {
286
313
  if (capturedValue.length < minSecretLength && pattern.type !== 'private_key') {
287
314
  continue;
288
315
  }
316
+ if (pattern.type === 'database_url' && isCredentiallessLocalUrl(fullMatch)) {
317
+ continue;
318
+ }
319
+ if (pattern.type === 'openai_key' && looksLikeLowEntropyOpenAiKey(fullMatch)) {
320
+ continue;
321
+ }
322
+ if (pattern.type === 'env_variable') {
323
+ const envVal = fullMatch.split('=').slice(1).join('=').replace(/^['"]|['"]$/g, '').trim();
324
+ if (!envVal || isPlaceholderEnvValue(envVal)) {
325
+ continue;
326
+ }
327
+ }
289
328
  // Check ignore patterns
290
329
  const isIgnored = ignoreRegexes.some(ignore => {
291
330
  ignore.lastIndex = 0;
@@ -344,7 +383,7 @@ export function scanInputForCredentials(input, config = {}) {
344
383
  while ((entropyMatch = entropyRegex.exec(input)) !== null) {
345
384
  const value = entropyMatch[1];
346
385
  const entropy = shannonEntropy(value);
347
- if (entropy > 4.5 && value.length >= 24) {
386
+ if (entropy > 4.5 && value.length >= 24 && !isPlaceholderEnvValue(value)) {
348
387
  // Only add if not already caught by a more specific pattern
349
388
  const alreadyCaught = detections.some(d => d.position && entropyMatch.index >= d.position.start && entropyMatch.index < d.position.end);
350
389
  if (!alreadyCaught) {
@@ -361,20 +400,66 @@ export function scanInputForCredentials(input, config = {}) {
361
400
  }
362
401
  }
363
402
  }
403
+ // ── Confidence engine: score candidates and suppress allowed false positives ──
404
+ const useLearned = config.use_learned_feedback !== false && !!config.cwd;
405
+ const feedbackStore = useLearned && config.cwd
406
+ ? loadDLPFeedbackStoreSync(config.cwd)
407
+ : null;
408
+ const classified = detections.map(d => {
409
+ if (feedbackStore) {
410
+ const start = d.position?.start ?? 0;
411
+ const lineStart = input.lastIndexOf('\n', start) + 1;
412
+ const lineEnd = input.indexOf('\n', start);
413
+ const line = input.slice(lineStart, lineEnd === -1 ? undefined : lineEnd).trim();
414
+ if (isLearnedAllowSync(feedbackStore, d.type, d.match, line)) {
415
+ return {
416
+ ...d,
417
+ confidence: 12,
418
+ decision: 'allow',
419
+ reason_codes: ['learned_false_positive'],
420
+ };
421
+ }
422
+ }
423
+ return {
424
+ ...d,
425
+ ...classifyDLPDetection(d, input),
426
+ };
427
+ });
428
+ // Line-level learned expansion: one false-positive mark allows sibling patterns on same line
429
+ const learnedStarts = new Set(classified
430
+ .filter(d => d.decision === 'allow' && d.reason_codes?.includes('learned_false_positive'))
431
+ .map(d => d.position?.start ?? -1));
432
+ const expanded = classified.map(d => {
433
+ const start = d.position?.start ?? -1;
434
+ if (start >= 0 && learnedStarts.has(start) && d.decision !== 'allow') {
435
+ return {
436
+ ...d,
437
+ confidence: 12,
438
+ decision: 'allow',
439
+ reason_codes: ['learned_false_positive'],
440
+ };
441
+ }
442
+ return d;
443
+ });
444
+ const actionable = expanded.filter(d => d.decision !== 'allow');
445
+ const allowed = expanded.filter(d => d.decision === 'allow');
364
446
  // Deduplicate overlapping detections (keep highest severity)
365
- const deduped = deduplicateDetections(detections);
447
+ const deduped = deduplicateDetections(actionable);
448
+ const allowedDeduped = deduplicateDetections(allowed);
366
449
  // Sort by severity (critical first)
367
450
  const severityOrder = { critical: 0, high: 1, medium: 2 };
368
451
  deduped.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]);
369
452
  const blockOnDetection = config.block_on_detection ?? true;
453
+ const hasBlock = deduped.some(d => d.decision === 'block');
370
454
  const status = deduped.length === 0
371
455
  ? 'clean'
372
- : blockOnDetection
456
+ : blockOnDetection && hasBlock
373
457
  ? 'blocked'
374
458
  : 'warning';
375
459
  return {
376
460
  status,
377
461
  detections: deduped,
462
+ ...(allowedDeduped.length > 0 ? { allowed_detections: allowedDeduped } : {}),
378
463
  duration_ms: Date.now() - start,
379
464
  scanned_length: input.length,
380
465
  };
@@ -391,7 +476,6 @@ function deduplicateDetections(detections) {
391
476
  return posA - posB;
392
477
  });
393
478
  const result = [];
394
- const severityOrder = { critical: 0, high: 1, medium: 2 };
395
479
  for (const detection of sorted) {
396
480
  const overlapping = result.find(existing => {
397
481
  if (!existing.position || !detection.position)
@@ -401,7 +485,7 @@ function deduplicateDetections(detections) {
401
485
  });
402
486
  if (overlapping) {
403
487
  // Keep the higher severity detection
404
- if (severityOrder[detection.severity] < severityOrder[overlapping.severity]) {
488
+ if (shouldReplaceDetection(detection, overlapping)) {
405
489
  const idx = result.indexOf(overlapping);
406
490
  result[idx] = detection;
407
491
  }
@@ -412,25 +496,45 @@ function deduplicateDetections(detections) {
412
496
  }
413
497
  return result;
414
498
  }
499
+ function shouldReplaceDetection(candidate, existing) {
500
+ const severityOrder = { critical: 0, high: 1, medium: 2 };
501
+ const decisionOrder = { block: 0, warn: 1, allow: 2 };
502
+ const severityDelta = severityOrder[candidate.severity] - severityOrder[existing.severity];
503
+ if (severityDelta !== 0)
504
+ return severityDelta < 0;
505
+ const candidateDecision = decisionOrder[candidate.decision ?? 'warn'];
506
+ const existingDecision = decisionOrder[existing.decision ?? 'warn'];
507
+ if (candidateDecision !== existingDecision)
508
+ return candidateDecision < existingDecision;
509
+ return (candidate.confidence ?? 0) > (existing.confidence ?? 0);
510
+ }
415
511
  /**
416
512
  * Format a blocked input result for display in IDE/terminal.
417
513
  * Used by hooks and CLI for human-readable output.
418
514
  */
419
515
  export function formatDLPAlert(result) {
420
516
  if (result.status === 'clean') {
517
+ const allowedCount = result.allowed_detections?.length ?? 0;
518
+ if (allowedCount > 0) {
519
+ return `✓ Input clean — allowed ${allowedCount} sample or safe credential-like value${allowedCount === 1 ? '' : 's'}.`;
520
+ }
421
521
  return '✓ Input clean — no credentials detected.';
422
522
  }
423
523
  const icon = result.status === 'blocked' ? '🛑' : '⚠️';
424
524
  const header = result.status === 'blocked'
425
- ? `${icon} BLOCKED — ${result.detections.length} credential(s) detected in agent input`
426
- : `${icon} WARNING — ${result.detections.length} credential(s) detected in agent input`;
525
+ ? `${icon} BLOCKED — likely live secret (${result.detections.length} finding${result.detections.length === 1 ? '' : 's'})`
526
+ : `${icon} WARNING — token-like value needs review (${result.detections.length} finding${result.detections.length === 1 ? '' : 's'})`;
427
527
  const details = result.detections.map(d => {
428
- const complianceStr = d.compliance.length > 0
528
+ const showCompliance = result.status === 'blocked' && d.compliance.length > 0;
529
+ const confidence = d.confidence !== undefined ? ` | Confidence: ${d.confidence}%` : '';
530
+ const reasons = d.reason_codes?.length ? `\n Why: ${formatReasonCodes(d.reason_codes)}` : '';
531
+ const complianceStr = showCompliance
429
532
  ? `\n Compliance: ${d.compliance.join(', ')}`
430
533
  : '';
431
534
  return [
432
- ` [${d.severity.toUpperCase()}] ${d.description}`,
535
+ ` [${d.severity.toUpperCase()}] ${d.description}${confidence}`,
433
536
  ` Detected: ${d.redacted}`,
537
+ reasons,
434
538
  ` → ${d.recommendation}`,
435
539
  complianceStr,
436
540
  ].filter(Boolean).join('\n');
@@ -454,7 +558,22 @@ export function createDLPAuditEntry(result, metadata) {
454
558
  severity: d.severity,
455
559
  redacted: d.redacted,
456
560
  description: d.description,
561
+ confidence: d.confidence,
562
+ decision: d.decision,
563
+ reason_codes: d.reason_codes,
457
564
  compliance: d.compliance,
458
565
  })),
566
+ allowed_detections: (result.allowed_detections ?? []).map(d => ({
567
+ type: d.type,
568
+ severity: d.severity,
569
+ redacted: d.redacted,
570
+ description: d.description,
571
+ confidence: d.confidence,
572
+ decision: d.decision,
573
+ reason_codes: d.reason_codes,
574
+ })),
459
575
  };
460
576
  }
577
+ function formatReasonCodes(reasonCodes) {
578
+ return reasonCodes.map(reason => reason.replace(/_/g, ' ')).join(', ');
579
+ }
@@ -7,14 +7,16 @@ import { scanInputForCredentials, formatDLPAlert, createDLPAuditEntry, } from '.
7
7
  // ── Cloud Provider Keys ──────────────────────────────────────────
8
8
  describe('scanInputForCredentials — AWS', () => {
9
9
  it('detects AWS Access Key IDs', () => {
10
- const result = scanInputForCredentials('Here is my key: AKIAIOSFODNN7EXAMPLE');
10
+ const result = scanInputForCredentials('Here is my key: AKIAZ9Y8X7W6V5U4T3Q2');
11
11
  expect(result.status).toBe('blocked');
12
12
  expect(result.detections).toHaveLength(1);
13
13
  expect(result.detections[0].type).toBe('aws_access_key');
14
14
  expect(result.detections[0].severity).toBe('critical');
15
+ expect(result.detections[0].decision).toBe('block');
16
+ expect(result.detections[0].confidence).toBeGreaterThanOrEqual(80);
15
17
  });
16
18
  it('detects AWS Secret Key assignments', () => {
17
- const result = scanInputForCredentials('aws_secret_access_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"');
19
+ const result = scanInputForCredentials('aws_secret_access_key = "Z9Y8X7W6V5U4T3S2R1Q0P9O8N7M6B5C4A3D2E1F0"');
18
20
  expect(result.status).toBe('blocked');
19
21
  expect(result.detections.some(d => d.type === 'aws_secret_key')).toBe(true);
20
22
  });
@@ -38,27 +40,27 @@ describe('scanInputForCredentials — Azure', () => {
38
40
  // ── API Keys (Provider-Specific) ─────────────────────────────────
39
41
  describe('scanInputForCredentials — API keys', () => {
40
42
  it('detects OpenAI key', () => {
41
- const result = scanInputForCredentials('sk-proj-abc1234567890ABCDEFGH');
43
+ const result = scanInputForCredentials('sk-proj-Z9Y8X7W6V5U4T3S2R1Q0P9O8N7M6');
42
44
  expect(result.status).toBe('blocked');
43
45
  expect(result.detections[0].type).toBe('openai_key');
44
46
  });
45
47
  it('detects Anthropic key', () => {
46
- const result = scanInputForCredentials('sk-ant-api03-abcdefghijklmnop123456');
48
+ const result = scanInputForCredentials('sk-ant-api03-Z9Y8X7W6V5U4T3S2R1Q0');
47
49
  expect(result.status).toBe('blocked');
48
50
  expect(result.detections[0].type).toBe('anthropic_key');
49
51
  });
50
52
  it('detects GitHub PAT', () => {
51
- const result = scanInputForCredentials('ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefgh12');
53
+ const result = scanInputForCredentials('ghp_Z9Y8X7W6V5U4T3S2R1Q0P9O8N7M6B5C4A3D2');
52
54
  expect(result.status).toBe('blocked');
53
55
  expect(result.detections[0].type).toBe('github_token');
54
56
  });
55
57
  it('detects Stripe live key', () => {
56
- const result = scanInputForCredentials('sk_live_51HxAbCdEfGhIjKlMnOpQrStU');
58
+ const result = scanInputForCredentials('sk_live_Z9Y8X7W6V5U4T3S2R1Q0P9O8');
57
59
  expect(result.status).toBe('blocked');
58
60
  expect(result.detections[0].type).toBe('stripe_key');
59
61
  });
60
62
  it('detects SendGrid key', () => {
61
- const result = scanInputForCredentials('SG.abcdefghijklmnopqrstuv.1234567890abcdefghijklmnopqrstuvwxyz1234567');
63
+ const result = scanInputForCredentials('SG.Z9Y8X7W6V5U4T3S2R1Q0P9.Z9Y8X7W6V5U4T3S2R1Q0P9O8N7M6B5C4A3D2E1F0GH7');
62
64
  expect(result.status).toBe('blocked');
63
65
  expect(result.detections[0].type).toBe('sendgrid_key');
64
66
  });
@@ -88,7 +90,7 @@ describe('scanInputForCredentials — Database URLs', () => {
88
90
  expect(dbDetection).toBeDefined();
89
91
  });
90
92
  it('detects MongoDB connection string', () => {
91
- const result = scanInputForCredentials('mongodb+srv://admin:s3cret@cluster0.abc123.mongodb.net/production');
93
+ const result = scanInputForCredentials('mongodb+srv://admin:s3cret@cluster0.prod.mongodb.net/production');
92
94
  expect(result.status).toBe('blocked');
93
95
  });
94
96
  it('detects Redis connection string', () => {
@@ -107,24 +109,30 @@ describe('scanInputForCredentials — Tokens', () => {
107
109
  });
108
110
  // ── Generic Patterns ─────────────────────────────────────────────
109
111
  describe('scanInputForCredentials — Generic patterns', () => {
110
- it('detects password assignment', () => {
111
- const result = scanInputForCredentials("password = 'SuperSecret123!'");
112
- expect(result.status).toBe('blocked');
113
- expect(result.detections[0].type).toBe('password_assignment');
112
+ it('allows ambiguous short password assignment without high entropy', () => {
113
+ const result = scanInputForCredentials("password = 'WinterAccess987!'");
114
+ expect(result.status).toBe('clean');
114
115
  });
115
- it('detects api_key assignment', () => {
116
+ it('allows placeholder api_key assignment', () => {
116
117
  const result = scanInputForCredentials('api_key: "abcdefghijklmnopqrstuvwxyz"');
117
- expect(result.status).toBe('blocked');
118
+ expect(result.status).toBe('clean');
119
+ expect(result.detections).toHaveLength(0);
120
+ expect(result.allowed_detections?.[0].reason_codes).toContain('safe_example_value');
118
121
  });
119
- it('detects .env format', () => {
120
- const result = scanInputForCredentials('DATABASE_PASSWORD=myS3cr3tP@ssw0rd!');
121
- expect(result.status).toBe('blocked');
122
+ it('warns on high-entropy api_key assignment', () => {
123
+ const result = scanInputForCredentials('api_key: "Z9y8X7w6V5u4T3s2R1q0P9o8N7m6B5c4"');
124
+ expect(result.status).toBe('warning');
125
+ expect(result.detections[0].decision).toBe('warn');
126
+ expect(result.detections[0].reason_codes).toContain('high_entropy');
127
+ });
128
+ it('warns on high-entropy .env format values', () => {
129
+ const result = scanInputForCredentials('DATABASE_PASSWORD=Z9y8X7w6V5u4T3s2R1q0P9o8N7m6B5c4Xy');
130
+ expect(result.status).toBe('warning');
122
131
  expect(result.detections.some(d => d.type === 'env_variable')).toBe(true);
123
132
  });
124
- it('detects URL with embedded credentials', () => {
133
+ it('allows placeholder URL with embedded credentials', () => {
125
134
  const result = scanInputForCredentials('http://admin:password123@internal-api.company.com/v1');
126
- expect(result.status).toBe('blocked');
127
- expect(result.detections.some(d => d.type === 'credentials_in_url')).toBe(true);
135
+ expect(result.status).toBe('clean');
128
136
  });
129
137
  });
130
138
  // ── Clean Input ──────────────────────────────────────────────────
@@ -142,6 +150,27 @@ describe('scanInputForCredentials — Clean input', () => {
142
150
  const result = scanInputForCredentials('password = "xxx"');
143
151
  expect(result.status).toBe('clean'); // too short
144
152
  });
153
+ it('returns clean for SSH host aliases without sensitive options', () => {
154
+ const result = scanInputForCredentials('ssh deploy@staging');
155
+ expect(result.status).toBe('clean');
156
+ });
157
+ it('returns clean for public Stripe keys', () => {
158
+ const result = scanInputForCredentials('STRIPE_PUBLIC_KEY=pk_test_123456789012345678901234');
159
+ expect(result.status).toBe('clean');
160
+ expect(result.allowed_detections?.[0].reason_codes).toContain('public_or_test_key');
161
+ });
162
+ it('returns clean for fake provider samples in docs', () => {
163
+ const result = scanInputForCredentials('// example: AKIAIOSFODNN7EXAMPLE');
164
+ expect(result.status).toBe('clean');
165
+ });
166
+ it('returns clean for checksum lines', () => {
167
+ const result = scanInputForCredentials('sha256:abcdefabcdefabcdefabcdefabcdefabcdef');
168
+ expect(result.status).toBe('clean');
169
+ });
170
+ it('returns clean for env variable references', () => {
171
+ const result = scanInputForCredentials('NPM_TOKEN=${NPM_TOKEN}');
172
+ expect(result.status).toBe('clean');
173
+ });
145
174
  });
146
175
  // ── Config Options ───────────────────────────────────────────────
147
176
  describe('scanInputForCredentials — Config', () => {
@@ -150,7 +179,7 @@ describe('scanInputForCredentials — Config', () => {
150
179
  expect(result.status).toBe('clean');
151
180
  });
152
181
  it('returns warning instead of blocked when block_on_detection is false', () => {
153
- const result = scanInputForCredentials('AKIAIOSFODNN7EXAMPLE', { block_on_detection: false });
182
+ const result = scanInputForCredentials('AKIAZ9Y8X7W6V5U4T3Q2', { block_on_detection: false });
154
183
  expect(result.status).toBe('warning');
155
184
  expect(result.detections).toHaveLength(1);
156
185
  });
@@ -163,7 +192,7 @@ describe('scanInputForCredentials — Config', () => {
163
192
  const result = scanInputForCredentials('internal-token-XYZ123456', {
164
193
  custom_patterns: ['internal-token-[A-Z0-9]+'],
165
194
  });
166
- expect(result.status).toBe('blocked');
195
+ expect(result.status).toBe('warning');
167
196
  expect(result.detections[0].type).toBe('custom_pattern');
168
197
  });
169
198
  it('respects ignore patterns', () => {
@@ -190,27 +219,26 @@ describe('scanInputForCredentials — Performance', () => {
190
219
  describe('scanInputForCredentials — Deduplication', () => {
191
220
  it('deduplicates overlapping detections and keeps higher severity', () => {
192
221
  // Input that might trigger both generic password_assignment and a more specific pattern
193
- const input = 'api_key = "sk-proj-abc1234567890ABCDEFGH"';
222
+ const input = 'api_key = "sk-proj-Z9Y8X7W6V5U4T3S2R1Q0P9O8N7M6"';
194
223
  const result = scanInputForCredentials(input);
195
224
  // Should not have duplicate detections for the same match region
196
225
  const positions = result.detections.map(d => d.position?.start);
197
226
  const uniquePositions = new Set(positions);
198
- // The exact count depends on pattern overlaps, but there should be some deduplication
199
- expect(result.detections.length).toBeLessThanOrEqual(positions.length);
227
+ expect(uniquePositions.size).toBe(positions.length);
200
228
  });
201
229
  });
202
230
  // ── Redaction ────────────────────────────────────────────────────
203
231
  describe('scanInputForCredentials — Redaction', () => {
204
232
  it('redacts matched credentials', () => {
205
- const result = scanInputForCredentials('AKIAIOSFODNN7EXAMPLE');
233
+ const result = scanInputForCredentials('AKIAZ9Y8X7W6V5U4T3Q2');
206
234
  expect(result.detections[0].redacted).toContain('****');
207
- expect(result.detections[0].redacted).not.toBe('AKIAIOSFODNN7EXAMPLE');
235
+ expect(result.detections[0].redacted).not.toBe('AKIAZ9Y8X7W6V5U4T3Q2');
208
236
  });
209
237
  });
210
238
  // ── Compliance ───────────────────────────────────────────────────
211
239
  describe('scanInputForCredentials — Compliance', () => {
212
240
  it('includes compliance tags for AWS keys', () => {
213
- const result = scanInputForCredentials('AKIAIOSFODNN7EXAMPLE');
241
+ const result = scanInputForCredentials('AKIAZ9Y8X7W6V5U4T3Q2');
214
242
  expect(result.detections[0].compliance).toContain('SOC2-CC6.1');
215
243
  expect(result.detections[0].compliance).toContain('HIPAA-164.312');
216
244
  expect(result.detections[0].compliance).toContain('PCI-DSS-3.4');
@@ -224,28 +252,30 @@ describe('formatDLPAlert', () => {
224
252
  expect(alert).toContain('clean');
225
253
  });
226
254
  it('shows BLOCKED header when credentials found', () => {
227
- const result = scanInputForCredentials('AKIAIOSFODNN7EXAMPLE');
255
+ const result = scanInputForCredentials('AKIAZ9Y8X7W6V5U4T3Q2');
228
256
  const alert = formatDLPAlert(result);
229
257
  expect(alert).toContain('BLOCKED');
230
- expect(alert).toContain('credential');
258
+ expect(alert).toContain('likely live secret');
231
259
  });
232
260
  it('shows WARNING header when block_on_detection is false', () => {
233
- const result = scanInputForCredentials('AKIAIOSFODNN7EXAMPLE', { block_on_detection: false });
261
+ const result = scanInputForCredentials('AKIAZ9Y8X7W6V5U4T3Q2', { block_on_detection: false });
234
262
  const alert = formatDLPAlert(result);
235
263
  expect(alert).toContain('WARNING');
236
264
  });
237
265
  it('includes severity, redacted value, and recommendation', () => {
238
- const result = scanInputForCredentials('AKIAIOSFODNN7EXAMPLE');
266
+ const result = scanInputForCredentials('AKIAZ9Y8X7W6V5U4T3Q2');
239
267
  const alert = formatDLPAlert(result);
240
268
  expect(alert).toContain('CRITICAL');
241
269
  expect(alert).toContain('****');
270
+ expect(alert).toContain('Confidence');
271
+ expect(alert).toContain('Why');
242
272
  expect(alert).toContain('process.env');
243
273
  });
244
274
  });
245
275
  // ── createDLPAuditEntry ──────────────────────────────────────────
246
276
  describe('createDLPAuditEntry', () => {
247
277
  it('creates structured audit entry', () => {
248
- const result = scanInputForCredentials('AKIAIOSFODNN7EXAMPLE');
278
+ const result = scanInputForCredentials('AKIAZ9Y8X7W6V5U4T3Q2');
249
279
  const entry = createDLPAuditEntry(result, { agent: 'claude', userId: 'test-user' });
250
280
  expect(entry.type).toBe('dlp_event');
251
281
  expect(entry.agent).toBe('claude');
@@ -261,11 +291,39 @@ describe('createDLPAuditEntry', () => {
261
291
  expect(entry.timestamp).toBe(ts);
262
292
  });
263
293
  it('redacts credentials in audit log (no raw match)', () => {
264
- const result = scanInputForCredentials('AKIAIOSFODNN7EXAMPLE');
294
+ const result = scanInputForCredentials('AKIAZ9Y8X7W6V5U4T3Q2');
265
295
  const entry = createDLPAuditEntry(result, { agent: 'claude' });
266
296
  const detections = entry.detections;
267
297
  // Audit entry should have redacted field but NOT the raw match
268
298
  expect(detections[0].redacted).toBeDefined();
269
299
  expect(detections[0].match).toBeUndefined();
300
+ expect(detections[0].confidence).toBeDefined();
301
+ expect(detections[0].decision).toBe('block');
302
+ });
303
+ });
304
+ describe('false positive regression', () => {
305
+ it('allows engineering prompts about API key storage', () => {
306
+ const result = scanInputForCredentials('Should Cursor Admin API keys be stored in ~/.rigour/settings.json under cursor.apiKey?');
307
+ expect(result.status).toBe('clean');
308
+ });
309
+ it('allows credential-less localhost database URLs', () => {
310
+ expect(scanInputForCredentials('redis://localhost:6379').status).toBe('clean');
311
+ expect(scanInputForCredentials('mongodb://localhost:27017/test').status).toBe('clean');
312
+ });
313
+ it('allows placeholder env assignments', () => {
314
+ expect(scanInputForCredentials('API_KEY=your-key-here').status).toBe('clean');
315
+ expect(scanInputForCredentials('api_key = "replace-me"').status).toBe('clean');
316
+ });
317
+ it('allows bare SK-like token without Twilio context', () => {
318
+ expect(scanInputForCredentials('SK-test-fixture-not-a-real-twilio-key').status).toBe('clean');
319
+ });
320
+ it('allows sk-proj examples in comments', () => {
321
+ expect(scanInputForCredentials('// sk-proj-abcdefghijklmnopqrstuvwxyz').status).toBe('clean');
322
+ });
323
+ it('still blocks live production database URLs', () => {
324
+ expect(scanInputForCredentials('postgresql://user:password@prod-server:5432/mydb').status).toBe('blocked');
325
+ });
326
+ it('still blocks live AWS keys in prose', () => {
327
+ expect(scanInputForCredentials('Here is my key: AKIAZ9Y8X7W6V5U4T3Q2').status).toBe('blocked');
270
328
  });
271
329
  });
package/dist/index.d.ts CHANGED
@@ -12,7 +12,7 @@ export * from './utils/logger.js';
12
12
  export { FileScanner } from './utils/scanner.js';
13
13
  export * from './services/score-history.js';
14
14
  export * from './hooks/index.js';
15
- export { loadSettings, saveSettings, getSettingsPath, resolveDeepOptions, getProviderKey, getAgentConfig, getCliPreferences, updateProviderKey, removeProviderKey } from './settings.js';
15
+ export { loadSettings, saveSettings, getSettingsPath, resolveDeepOptions, getProviderKey, getAgentConfig, getCliPreferences, updateProviderKey, removeProviderKey, getCursorApiKey, updateCursorApiKey } from './settings.js';
16
16
  export type { RigourSettings, ResolvedDeepOptions, CLIDeepOptions } from './settings.js';
17
17
  export { DeepAnalysisGate } from './gates/deep-analysis.js';
18
18
  export { createProvider } from './inference/index.js';
@@ -32,3 +32,8 @@ export { IncrementalCache } from './services/incremental-cache.js';
32
32
  export type { IncrementalResult } from './services/incremental-cache.js';
33
33
  export { renderScoreGauge, renderSeveritySection, renderGateGrid, renderBrainStatus, renderFullReport, renderMcpHeadline, renderFixAttribution } from './services/terminal-renderer.js';
34
34
  export type { RenderOptions, GateResult } from './services/terminal-renderer.js';
35
+ export * from './context/cache-engine.js';
36
+ export * from './context/context-session.js';
37
+ export * from './context/index-bridge.js';
38
+ export * from './services/context-telemetry-service.js';
39
+ export { recordContextEvent, recordModelUsage, recordCheckpointMetric, getContextEvents, getModelUsages, getCheckpointMetrics, } from './storage/index.js';
package/dist/index.js CHANGED
@@ -13,7 +13,7 @@ export { FileScanner } from './utils/scanner.js';
13
13
  export * from './services/score-history.js';
14
14
  export * from './hooks/index.js';
15
15
  // Settings Module (Global user config at ~/.rigour/settings.json)
16
- export { loadSettings, saveSettings, getSettingsPath, resolveDeepOptions, getProviderKey, getAgentConfig, getCliPreferences, updateProviderKey, removeProviderKey } from './settings.js';
16
+ export { loadSettings, saveSettings, getSettingsPath, resolveDeepOptions, getProviderKey, getAgentConfig, getCliPreferences, updateProviderKey, removeProviderKey, getCursorApiKey, updateCursorApiKey } from './settings.js';
17
17
  // Deep Analysis Pipeline (v4.0+)
18
18
  export { DeepAnalysisGate } from './gates/deep-analysis.js';
19
19
  export { createProvider } from './inference/index.js';
@@ -32,6 +32,12 @@ export { getProvenanceTrends, getQualityTrend } from './services/adaptive-thresh
32
32
  export { IncrementalCache } from './services/incremental-cache.js';
33
33
  // Terminal Renderer (rich CLI/MCP output)
34
34
  export { renderScoreGauge, renderSeveritySection, renderGateGrid, renderBrainStatus, renderFullReport, renderMcpHeadline, renderFixAttribution } from './services/terminal-renderer.js';
35
+ // Context Telemetry & 4-Layer Caching Engine
36
+ export * from './context/cache-engine.js';
37
+ export * from './context/context-session.js';
38
+ export * from './context/index-bridge.js';
39
+ export * from './services/context-telemetry-service.js';
40
+ export { recordContextEvent, recordModelUsage, recordCheckpointMetric, getContextEvents, getModelUsages, getCheckpointMetrics, } from './storage/index.js';
35
41
  // Pattern Index is intentionally NOT exported here to prevent
36
42
  // native dependency issues (sharp/transformers) from leaking into
37
43
  // non-AI parts of the system.