@rigour-labs/core 5.5.0 → 5.5.1

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.
@@ -12,6 +12,7 @@ import fs from 'fs-extra';
12
12
  import path from 'path';
13
13
  import ts from 'typescript';
14
14
  import { isNodeBuiltin } from '../hallucinated-imports-stdlib.js';
15
+ import { resolveTsPathTarget } from './ts-path-target.js';
15
16
  /**
16
17
  * Check JavaScript/TypeScript imports in a source file and add hallucinated findings.
17
18
  */
@@ -148,7 +149,7 @@ export async function resolveTsPathAlias(file, importPath, cwd, projectFiles, ts
148
149
  continue;
149
150
  for (const target of rule.targets) {
150
151
  const candidatePattern = rule.hasWildcard ? target.replace('*', wildcard) : target;
151
- if (resolveTsPathTarget(config.baseDir, candidatePattern, cwd, projectFiles)) {
152
+ if (await resolveTsPathTarget(config.baseDir, candidatePattern, cwd, projectFiles)) {
152
153
  return true;
153
154
  }
154
155
  }
@@ -169,20 +170,6 @@ function matchTsPathRule(rule, importPath) {
169
170
  }
170
171
  return importPath.slice(rule.prefix.length, importPath.length - rule.suffix.length);
171
172
  }
172
- /**
173
- * Check if a TypeScript path target resolves to an actual project file.
174
- */
175
- function resolveTsPathTarget(baseDir, candidatePattern, cwd, projectFiles) {
176
- const absolute = path.resolve(baseDir, candidatePattern);
177
- const relative = path.relative(cwd, absolute).replace(/\\/g, '/');
178
- const normalized = relative.replace(/\/$/, '');
179
- const extensions = ['', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.d.ts'];
180
- const candidates = [
181
- ...extensions.map(ext => normalized + ext),
182
- ...extensions.map(ext => `${normalized}/index${ext}`),
183
- ];
184
- return candidates.some(c => projectFiles.has(c));
185
- }
186
173
  /**
187
174
  * Find and load TypeScript/JavaScript config for a file.
188
175
  * Walks up the directory tree to find tsconfig.json or jsconfig.json.
@@ -248,6 +235,12 @@ async function loadTsPathConfig(searchDir) {
248
235
  continue;
249
236
  rules.push({ key, hasWildcard, prefix, suffix, targets });
250
237
  }
238
+ rules.sort((left, right) => {
239
+ if (left.hasWildcard !== right.hasWildcard)
240
+ return left.hasWildcard ? 1 : -1;
241
+ return (right.prefix.length + right.suffix.length)
242
+ - (left.prefix.length + left.suffix.length);
243
+ });
251
244
  if (rules.length === 0)
252
245
  continue;
253
246
  return { baseDir: resolvedBaseDir, rules };
@@ -0,0 +1 @@
1
+ export declare function resolveTsPathTarget(baseDir: string, candidatePattern: string, cwd: string, projectFiles: Set<string>): Promise<boolean>;
@@ -0,0 +1,27 @@
1
+ import { pathExists, stat } from 'fs-extra';
2
+ import path from 'path';
3
+ const sourceExtensions = ['', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.d.ts'];
4
+ export async function resolveTsPathTarget(baseDir, candidatePattern, cwd, projectFiles) {
5
+ const absolute = path.resolve(baseDir, candidatePattern);
6
+ const relative = path.relative(cwd, absolute).replace(/\\/g, '/');
7
+ if (relative === '..' || relative.startsWith('../') || path.isAbsolute(relative))
8
+ return false;
9
+ const normalized = relative.replace(/\/$/, '');
10
+ const candidates = [
11
+ ...sourceExtensions.map(extension => normalized + extension),
12
+ ...sourceExtensions.map(extension => `${normalized}/index${extension}`),
13
+ ];
14
+ if (candidates.some(candidate => projectFiles.has(candidate)))
15
+ return true;
16
+ for (const candidate of candidates) {
17
+ const candidatePath = path.resolve(cwd, candidate);
18
+ if (!await pathExists(candidatePath))
19
+ continue;
20
+ try {
21
+ if ((await stat(candidatePath)).isFile())
22
+ return true;
23
+ }
24
+ catch { /* inaccessible targets do not resolve */ }
25
+ }
26
+ return false;
27
+ }
@@ -11,13 +11,14 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
11
11
  import { HallucinatedImportsGate } from './hallucinated-imports/index.js';
12
12
  import path from 'path';
13
13
  // Mock fs-extra — vi.hoisted ensures these are available when vi.mock runs (hoisted)
14
- const { mockPathExists, mockPathExistsSync, mockReadFile, mockReadFileSync, mockReadJson, mockReaddirSync } = vi.hoisted(() => ({
14
+ const { mockPathExists, mockPathExistsSync, mockReadFile, mockReadFileSync, mockReadJson, mockReaddirSync, mockStat } = vi.hoisted(() => ({
15
15
  mockPathExists: vi.fn(),
16
16
  mockPathExistsSync: vi.fn(),
17
17
  mockReadFile: vi.fn(),
18
18
  mockReadFileSync: vi.fn(),
19
19
  mockReadJson: vi.fn(),
20
20
  mockReaddirSync: vi.fn().mockReturnValue([]),
21
+ mockStat: vi.fn(),
21
22
  }));
22
23
  vi.mock('fs-extra', () => {
23
24
  const mock = {
@@ -27,6 +28,7 @@ vi.mock('fs-extra', () => {
27
28
  readFileSync: mockReadFileSync,
28
29
  readJson: mockReadJson,
29
30
  readdirSync: mockReaddirSync,
31
+ stat: mockStat,
30
32
  };
31
33
  return {
32
34
  ...mock,
@@ -365,6 +367,7 @@ import { cfg } from '~shared/config';
365
367
  return normalized === `${testCwdNormalized}/apps/desktop/tsconfig.json`
366
368
  || normalized === `${testCwdNormalized}/package.json`;
367
369
  });
370
+ mockStat.mockResolvedValue({ isFile: () => true });
368
371
  mockReadJson.mockResolvedValue({
369
372
  dependencies: {},
370
373
  devDependencies: {},
@@ -410,6 +413,39 @@ import { cfg } from '~shared/config';
410
413
  expect(details.includes("Path alias '@/utils/missing' does not resolve to a project file")
411
414
  || details.includes("Package '@/utils' not in package.json dependencies")).toBe(true);
412
415
  });
416
+ it('should resolve configured aliases to hidden generated files', async () => {
417
+ const jsContent = `import { docs } from '@/.source';`;
418
+ const tsconfigContent = `{
419
+ "compilerOptions": {
420
+ "paths": {
421
+ "@/*": ["./*"],
422
+ "@/.source": ["./.source/index.ts"]
423
+ }
424
+ }
425
+ }`;
426
+ FileScanner.findFiles.mockResolvedValue(['docs-site/lib/source.ts']);
427
+ mockReadFile.mockImplementation(async (p) => {
428
+ const normalized = normalizePath(p);
429
+ if (normalized.endsWith('/docs-site/tsconfig.json'))
430
+ return tsconfigContent;
431
+ return jsContent;
432
+ });
433
+ mockPathExists.mockImplementation(async (p) => {
434
+ const normalized = normalizePath(p);
435
+ return normalized === `${testCwdNormalized}/docs-site/tsconfig.json`
436
+ || normalized === `${testCwdNormalized}/docs-site/.source/index.ts`
437
+ || normalized === `${testCwdNormalized}/package.json`;
438
+ });
439
+ mockStat.mockResolvedValue({ isFile: () => true });
440
+ mockReadJson.mockResolvedValue({
441
+ dependencies: {},
442
+ devDependencies: {},
443
+ peerDependencies: {},
444
+ optionalDependencies: {},
445
+ });
446
+ const failures = await gate.run(context);
447
+ expect(failures).toHaveLength(0);
448
+ });
413
449
  it('should NOT flag ESM .js specifiers that resolve to .ts source files', async () => {
414
450
  const jsContent = `
415
451
  import { helper } from './utils.js';
@@ -14,11 +14,14 @@ export interface DLPBlockManifest {
14
14
  timestamp: string;
15
15
  detections: Array<{
16
16
  type: string;
17
- match: string;
18
17
  fingerprint: string;
19
- shape: string;
18
+ confidence: number;
20
19
  }>;
21
20
  }
21
+ export declare function isLearnableDLPDetection(detection: {
22
+ type: string;
23
+ confidence?: number;
24
+ }): boolean;
22
25
  /** Normalize a matched value to a shape for learning (preserves structure, not secret content). */
23
26
  export declare function shapeValue(value: string): string;
24
27
  export declare function fingerprintDetection(type: string, match: string, line?: string): string;
@@ -28,9 +31,10 @@ export declare function lastBlockPath(cwd: string): string;
28
31
  export declare function recordDLPFeedback(cwd: string, entry: {
29
32
  type: string;
30
33
  match: string;
34
+ confidence?: number;
31
35
  line?: string;
32
36
  source?: DLPFeedbackEntry['source'];
33
- }): Promise<string>;
37
+ }): Promise<string | null>;
34
38
  export declare function isLearnedAllow(cwd: string | undefined, type: string, match: string, _line?: string): Promise<boolean>;
35
39
  export declare function isLearnedAllowSync(store: DLPFeedbackStore, type: string, match: string, _line?: string): boolean;
36
40
  export declare function loadDLPFeedbackStore(cwd: string): Promise<DLPFeedbackStore>;
@@ -39,10 +43,12 @@ export declare function getLearnedAllowCount(cwd: string): Promise<number>;
39
43
  export declare function loadDLPFeedbackStoreSync(cwd: string): DLPFeedbackStore;
40
44
  export declare function writeDLPBlockManifest(cwd: string, detections: Array<{
41
45
  type: string;
42
- match: string;
46
+ match?: string;
47
+ feedback_fingerprint?: string;
48
+ confidence?: number;
43
49
  position?: {
44
50
  start: number;
45
51
  end: number;
46
52
  };
47
- }>, input: string): Promise<void>;
53
+ }>, _input: string): Promise<void>;
48
54
  export declare function allowLastDLPBlock(cwd: string, source?: DLPFeedbackEntry['source']): Promise<number>;
@@ -5,6 +5,31 @@
5
5
  import { createHash } from 'crypto';
6
6
  import fs from 'fs-extra';
7
7
  import path from 'path';
8
+ const DLP_BLOCK_MANIFEST_TTL_MS = 10 * 60 * 1000;
9
+ const MAX_LEARNABLE_CONFIDENCE = 79;
10
+ const NON_LEARNABLE_TYPES = new Set([
11
+ 'private_key',
12
+ 'private_key_full',
13
+ 'gcp_service_account',
14
+ 'bearer_token',
15
+ 'jwt_token',
16
+ 'aws_secret_key',
17
+ 'azure_key',
18
+ 'anthropic_key',
19
+ 'slack_token',
20
+ 'sendgrid_key',
21
+ 'aws_access_key',
22
+ 'openai_key',
23
+ 'github_token',
24
+ 'stripe_key',
25
+ 'twilio_key',
26
+ 'high_entropy_secret',
27
+ ]);
28
+ export function isLearnableDLPDetection(detection) {
29
+ const confidence = detection.confidence ?? 100;
30
+ return !NON_LEARNABLE_TYPES.has(detection.type)
31
+ && confidence <= MAX_LEARNABLE_CONFIDENCE;
32
+ }
8
33
  /** Normalize a matched value to a shape for learning (preserves structure, not secret content). */
9
34
  export function shapeValue(value) {
10
35
  return value
@@ -55,8 +80,9 @@ async function saveStore(cwd, store) {
55
80
  await fs.writeJson(feedbackPath(cwd), store, { spaces: 2 });
56
81
  }
57
82
  export async function recordDLPFeedback(cwd, entry) {
83
+ if (!isLearnableDLPDetection(entry))
84
+ return null;
58
85
  const fingerprint = feedbackFingerprint(entry.type, entry.match);
59
- const shape = extractValueShape(entry.match);
60
86
  const store = await loadStore(cwd);
61
87
  const existing = store.entries.find(e => e.fingerprint === fingerprint);
62
88
  const now = new Date().toISOString();
@@ -68,7 +94,7 @@ export async function recordDLPFeedback(cwd, entry) {
68
94
  store.entries.push({
69
95
  fingerprint,
70
96
  type: entry.type,
71
- shape,
97
+ shape: '[redacted]',
72
98
  hits: 1,
73
99
  lastSeenAt: now,
74
100
  source: entry.source ?? 'hook',
@@ -119,37 +145,62 @@ function getLineAt(input, start) {
119
145
  const lineEnd = input.indexOf('\n', start);
120
146
  return input.slice(lineStart, lineEnd === -1 ? undefined : lineEnd).trim();
121
147
  }
122
- export async function writeDLPBlockManifest(cwd, detections, input) {
148
+ export async function writeDLPBlockManifest(cwd, detections, _input) {
123
149
  const seen = new Set();
124
150
  const manifestDetections = [];
125
151
  for (const d of detections) {
126
- const fp = feedbackFingerprint(d.type, d.match);
152
+ if (!isLearnableDLPDetection(d))
153
+ continue;
154
+ const fp = d.feedback_fingerprint
155
+ ?? (d.match ? feedbackFingerprint(d.type, d.match) : null);
156
+ if (!fp)
157
+ continue;
127
158
  if (seen.has(fp))
128
159
  continue;
129
160
  seen.add(fp);
130
161
  manifestDetections.push({
131
162
  type: d.type,
132
- match: d.match.slice(0, 80),
133
163
  fingerprint: fp,
134
- shape: extractValueShape(d.match),
164
+ confidence: d.confidence ?? 100,
135
165
  });
136
166
  }
167
+ const manifestPath = lastBlockPath(cwd);
168
+ if (manifestDetections.length === 0) {
169
+ await fs.remove(manifestPath);
170
+ return;
171
+ }
137
172
  const manifest = {
138
173
  timestamp: new Date().toISOString(),
139
174
  detections: manifestDetections,
140
175
  };
141
- await fs.ensureDir(path.dirname(lastBlockPath(cwd)));
142
- await fs.writeJson(lastBlockPath(cwd), manifest, { spaces: 2 });
176
+ await fs.ensureDir(path.dirname(manifestPath));
177
+ await fs.writeJson(manifestPath, manifest, { spaces: 2 });
143
178
  }
144
179
  export async function allowLastDLPBlock(cwd, source = 'hook') {
145
180
  const manifestPath = lastBlockPath(cwd);
146
181
  if (!await fs.pathExists(manifestPath))
147
182
  return 0;
148
- const manifest = await fs.readJson(manifestPath);
183
+ let manifest;
184
+ try {
185
+ manifest = await fs.readJson(manifestPath);
186
+ }
187
+ catch {
188
+ await fs.remove(manifestPath);
189
+ return 0;
190
+ }
191
+ await fs.remove(manifestPath);
192
+ const manifestAge = Date.now() - Date.parse(manifest.timestamp);
193
+ if (!Number.isFinite(manifestAge)
194
+ || manifestAge < 0
195
+ || manifestAge > DLP_BLOCK_MANIFEST_TTL_MS
196
+ || !Array.isArray(manifest.detections))
197
+ return 0;
149
198
  const store = await loadStore(cwd);
150
199
  const now = new Date().toISOString();
151
200
  let count = 0;
152
201
  for (const d of manifest.detections ?? []) {
202
+ if (!isLearnableDLPDetection(d))
203
+ continue;
153
204
  const existing = store.entries.find(e => e.fingerprint === d.fingerprint);
154
205
  if (existing) {
155
206
  existing.hits += 1;
@@ -159,7 +210,7 @@ export async function allowLastDLPBlock(cwd, source = 'hook') {
159
210
  store.entries.push({
160
211
  fingerprint: d.fingerprint,
161
212
  type: d.type,
162
- shape: d.shape,
213
+ shape: '[redacted]',
163
214
  hits: 1,
164
215
  lastSeenAt: now,
165
216
  source,
@@ -4,6 +4,7 @@ import os from 'os';
4
4
  import fs from 'fs-extra';
5
5
  import { recordDLPFeedback, feedbackFingerprint, loadDLPFeedbackStoreSync, isLearnedAllowSync, writeDLPBlockManifest, allowLastDLPBlock, } from './dlp-feedback.js';
6
6
  import { scanInputForCredentials } from './input-validator.js';
7
+ import { generateDLPHookFiles } from './dlp-templates.js';
7
8
  describe('DLP feedback learning', () => {
8
9
  const testCwd = path.join(os.tmpdir(), `rigour-dlp-fb-${Date.now()}`);
9
10
  beforeEach(async () => {
@@ -19,7 +20,12 @@ describe('DLP feedback learning', () => {
19
20
  });
20
21
  it('records and applies learned false positives', async () => {
21
22
  const match = 'api_key = "Z9y8X7w6V5u4T3s2R1q0P9o8N7m6B5c4"';
22
- await recordDLPFeedback(testCwd, { type: 'password_assignment', match, source: 'hook' });
23
+ await recordDLPFeedback(testCwd, {
24
+ type: 'password_assignment',
25
+ match,
26
+ confidence: 55,
27
+ source: 'hook',
28
+ });
23
29
  const store = loadDLPFeedbackStoreSync(testCwd);
24
30
  expect(isLearnedAllowSync(store, 'password_assignment', match)).toBe(true);
25
31
  const result = scanInputForCredentials(match, { cwd: testCwd });
@@ -36,4 +42,90 @@ describe('DLP feedback learning', () => {
36
42
  const retry = scanInputForCredentials(text, { cwd: testCwd });
37
43
  expect(retry.status).toBe('clean');
38
44
  });
45
+ it('never learns provider or high-confidence detections', async () => {
46
+ const provider = await recordDLPFeedback(testCwd, {
47
+ type: 'openai_key',
48
+ match: 'sk-proj-Z9Y8X7W6V5U4T3S2R1Q0P9O8N7M6',
49
+ confidence: 92,
50
+ });
51
+ const highConfidence = await recordDLPFeedback(testCwd, {
52
+ type: 'password_assignment',
53
+ match: 'password = "Z9y8X7w6V5u4T3s2R1q0P9o8N7m6B5c4"',
54
+ confidence: 95,
55
+ });
56
+ expect(provider).toBeNull();
57
+ expect(highConfidence).toBeNull();
58
+ expect(loadDLPFeedbackStoreSync(testCwd).entries).toHaveLength(0);
59
+ });
60
+ it('does not retain raw credential fragments in manifests', async () => {
61
+ const text = 'api_key = "Z9y8X7w6V5u4T3s2R1q0P9o8N7m6B5c4"';
62
+ const result = scanInputForCredentials(text);
63
+ expect(result.detections[0].match).toBe(result.detections[0].redacted);
64
+ expect(result.detections[0].match).not.toContain('Z9y8X7w6V5u4T3s2R1q0P9o8N7m6B5c4');
65
+ await writeDLPBlockManifest(testCwd, result.detections, text);
66
+ const manifest = fs.readFileSync(path.join(testCwd, '.rigour', 'dlp-last-block.json'), 'utf-8');
67
+ expect(manifest).not.toContain('Z9y8X7w6V5u4T3s2R1q0P9o8N7m6B5c4');
68
+ expect(manifest).not.toContain('match');
69
+ expect(manifest).not.toContain('shape');
70
+ });
71
+ it('rejects and consumes expired manifests', async () => {
72
+ const text = 'api_key = "Z9y8X7w6V5u4T3s2R1q0P9o8N7m6B5c4"';
73
+ const result = scanInputForCredentials(text);
74
+ await writeDLPBlockManifest(testCwd, result.detections, text);
75
+ const manifestPath = path.join(testCwd, '.rigour', 'dlp-last-block.json');
76
+ const manifest = fs.readJsonSync(manifestPath);
77
+ manifest.timestamp = '2020-01-01T00:00:00.000Z';
78
+ fs.writeJsonSync(manifestPath, manifest);
79
+ expect(await allowLastDLPBlock(testCwd, 'hook')).toBe(0);
80
+ expect(fs.existsSync(manifestPath)).toBe(false);
81
+ });
82
+ it('rejects and consumes malformed manifests', async () => {
83
+ const manifestPath = path.join(testCwd, '.rigour', 'dlp-last-block.json');
84
+ fs.writeFileSync(manifestPath, '{not-json');
85
+ expect(await allowLastDLPBlock(testCwd, 'hook')).toBe(0);
86
+ expect(fs.existsSync(manifestPath)).toBe(false);
87
+ });
88
+ });
89
+ describe('DLP scanner URL boundaries', () => {
90
+ it('detects genuine HTTP basic-auth credentials', () => {
91
+ const result = scanInputForCredentials('https://alice:S3cr3t-Prod-9842@example.com/v1');
92
+ expect(result.status).toBe('warning');
93
+ expect(result.detections.some(d => d.type === 'credentials_in_url')).toBe(true);
94
+ });
95
+ it('allows signed GCS URLs without URL user-info', () => {
96
+ const url = 'https://storage.googleapis.com/example/archive.zip'
97
+ + '?X-Goog-Credential=service%40example.invalid&X-Goog-Signature=redacted';
98
+ expect(scanInputForCredentials(url).status).toBe('clean');
99
+ });
100
+ it('does not cross multiline JSON boundaries while scanning URLs', () => {
101
+ const input = `{
102
+ "task_archive_url": "https://storage.googleapis.com/example/archive.zip",
103
+ "provider": { "npm": "@ai-sdk/openai-compatible" }
104
+ }`;
105
+ expect(scanInputForCredentials(input).status).toBe('clean');
106
+ });
107
+ it('allows nested OpenCode configuration with scoped npm packages', () => {
108
+ const config = JSON.stringify({
109
+ $schema: 'https://opencode.ai/config.json',
110
+ provider: {
111
+ moonshot: {
112
+ npm: '@ai-sdk/openai-compatible',
113
+ options: {
114
+ baseURL: 'https://api.moonshot.ai/v1',
115
+ apiKey: '{env:OPENAI_API_KEY}',
116
+ },
117
+ },
118
+ },
119
+ }, null, 2);
120
+ expect(scanInputForCredentials(config).status).toBe('clean');
121
+ });
122
+ });
123
+ describe('DLP hook warning templates', () => {
124
+ it.each(['cursor', 'cline', 'windsurf'])('generates non-blocking %s warnings', (tool) => {
125
+ const content = generateDLPHookFiles(tool, 'rigour hooks check')
126
+ .map(file => file.content)
127
+ .join('\n');
128
+ expect(content).toContain('possible credential');
129
+ expect(content).not.toContain('process.exit(2)');
130
+ });
39
131
  });
@@ -1,8 +1,8 @@
1
1
  /**
2
- * DLP Hook Templates — Pre-Input Credential Interception
2
+ * DLP Hook Templates — Pre-Input Credential Warnings
3
3
  *
4
4
  * Generates tool-native hook configs that intercept user input
5
- * BEFORE it reaches the AI agent. Complements the existing
5
+ * before agent actions. Complements the existing
6
6
  * post-output templates in templates.ts.
7
7
  *
8
8
  * Hook events:
@@ -1,8 +1,8 @@
1
1
  /**
2
- * DLP Hook Templates — Pre-Input Credential Interception
2
+ * DLP Hook Templates — Pre-Input Credential Warnings
3
3
  *
4
4
  * Generates tool-native hook configs that intercept user input
5
- * BEFORE it reaches the AI agent. Complements the existing
5
+ * before agent actions. Complements the existing
6
6
  * post-output templates in templates.ts.
7
7
  *
8
8
  * Hook events:
@@ -32,7 +32,7 @@ export function generateDLPHookFiles(tool, checkerCommand) {
32
32
  // ── Claude Code DLP Hook ──────────────────────────────────────────
33
33
  function generateClaudeDLPHooks(checkerCommand) {
34
34
  // Claude Code supports PreToolUse hooks that fire BEFORE tool execution.
35
- // We intercept all tool uses to scan user input for credentials.
35
+ // Scan tool input and report warnings without blocking by default.
36
36
  const settings = {
37
37
  hooks: {
38
38
  PreToolUse: [
@@ -102,13 +102,12 @@ process.stdin.on('end', async () => {
102
102
  if (proc.error) throw proc.error;
103
103
 
104
104
  const result = JSON.parse((proc.stdout || '').trim());
105
- if (result.status === 'blocked') {
106
- process.stderr.write('[rigour/dlp] ' + result.detections.length + ' credential(s) BLOCKED\\n');
105
+ if (result.status !== 'clean') {
106
+ process.stderr.write('[rigour/dlp] ' + result.detections.length + ' possible credential(s) detected\\n');
107
107
  for (const d of result.detections) {
108
- process.stderr.write(' [' + d.severity.toUpperCase() + '] ' + d.description + '\\n');
108
+ process.stderr.write(' [WARNING] ' + d.description + '\\n');
109
109
  process.stderr.write(' → ' + d.recommendation + '\\n');
110
110
  }
111
- process.exit(2); // Block the operation
112
111
  }
113
112
 
114
113
  process.stdout.write(JSON.stringify({ status: 'ok' }));
@@ -128,7 +127,7 @@ process.stdin.on('end', async () => {
128
127
  path: '.cursor/rigour-dlp-hook.js',
129
128
  content: wrapper,
130
129
  executable: true,
131
- description: 'Cursor DLP hook wrapper — credential interception',
130
+ description: 'Cursor DLP hook wrapper — credential warnings',
132
131
  },
133
132
  ];
134
133
  }
@@ -178,15 +177,14 @@ process.stdin.on('end', async () => {
178
177
  if (proc.error) throw proc.error;
179
178
 
180
179
  const result = JSON.parse((proc.stdout || '').trim());
181
- if (result.status === 'blocked') {
180
+ if (result.status !== 'clean') {
182
181
  const msgs = result.detections
183
182
  .map(d => '[rigour/dlp/' + d.type + '] ' + d.description + ' → ' + d.recommendation)
184
183
  .join('\\n');
185
184
 
186
185
  process.stdout.write(JSON.stringify({
187
- contextModification: '\\n🛑 [Rigour DLP] ' + result.detections.length + ' credential(s) BLOCKED before agent processing:\\n' + msgs + '\\nReplace with environment variable references.',
186
+ contextModification: '\\n⚠️ [Rigour DLP] ' + result.detections.length + ' possible credential(s) detected:\\n' + msgs,
188
187
  }));
189
- process.exit(2);
190
188
  } else {
191
189
  process.stdout.write(JSON.stringify({}));
192
190
  }
@@ -201,7 +199,7 @@ process.stdin.on('end', async () => {
201
199
  path: '.clinerules/hooks/PreToolUse',
202
200
  content: script,
203
201
  executable: true,
204
- description: 'Cline PreToolUse DLP hook — credential interception before agent execution',
202
+ description: 'Cline PreToolUse DLP hook — credential warnings before agent execution',
205
203
  },
206
204
  ];
207
205
  }
@@ -249,12 +247,11 @@ process.stdin.on('end', async () => {
249
247
  if (proc.error) throw proc.error;
250
248
 
251
249
  const result = JSON.parse((proc.stdout || '').trim());
252
- if (result.status === 'blocked') {
250
+ if (result.status !== 'clean') {
253
251
  for (const d of result.detections) {
254
- process.stderr.write('[rigour/dlp] 🛑 ' + d.severity.toUpperCase() + ': ' + d.description + '\\n');
252
+ process.stderr.write('[rigour/dlp] ⚠ possible credential: ' + d.description + '\\n');
255
253
  process.stderr.write(' → ' + d.recommendation + '\\n');
256
254
  }
257
- process.exit(2); // Block
258
255
  }
259
256
  } catch (err) {
260
257
  process.stderr.write('Rigour DLP hook error: ' + err.message + '\\n');
@@ -271,7 +268,7 @@ process.stdin.on('end', async () => {
271
268
  path: '.windsurf/rigour-dlp-hook.js',
272
269
  content: wrapper,
273
270
  executable: true,
274
- description: 'Windsurf DLP hook wrapper — credential interception before Cascade',
271
+ description: 'Windsurf DLP hook wrapper — credential warnings before Cascade',
275
272
  },
276
273
  ];
277
274
  }
@@ -19,7 +19,9 @@
19
19
  export interface CredentialDetection {
20
20
  type: string;
21
21
  severity: 'critical' | 'high' | 'medium';
22
+ /** Redacted match only. Raw credential material never leaves the scanner. */
22
23
  match: string;
24
+ feedback_fingerprint?: string;
23
25
  redacted: string;
24
26
  description: string;
25
27
  recommendation: string;
@@ -17,7 +17,7 @@
17
17
  *
18
18
  */
19
19
  import { classifyDLPDetection } from './dlp-confidence.js';
20
- import { isLearnedAllowSync, loadDLPFeedbackStoreSync } from './dlp-feedback.js';
20
+ import { feedbackFingerprint, isLearnableDLPDetection, isLearnedAllowSync, loadDLPFeedbackStoreSync, } from './dlp-feedback.js';
21
21
  const CREDENTIAL_PATTERNS = [
22
22
  // ── Cloud Provider Keys ───────────────────────────────────
23
23
  {
@@ -174,7 +174,7 @@ const CREDENTIAL_PATTERNS = [
174
174
  // ── IP Addresses with Credentials ─────────────────────────
175
175
  {
176
176
  type: 'credentials_in_url',
177
- regex: /\b(https?:\/\/[^:]+:[^@]+@[^\s'"`,;}{)]+)\b/gi,
177
+ regex: /\b(https?:\/\/[^/\s:@]+:[^/\s@]+@[^\s'"`,;}{)]+)\b/gi,
178
178
  severity: 'critical',
179
179
  description: 'URL with embedded credentials detected',
180
180
  recommendation: 'Remove credentials from URLs. Use environment variables for auth',
@@ -406,24 +406,25 @@ export function scanInputForCredentials(input, config = {}) {
406
406
  ? loadDLPFeedbackStoreSync(config.cwd)
407
407
  : null;
408
408
  const classified = detections.map(d => {
409
- if (feedbackStore) {
409
+ const baseline = {
410
+ ...d,
411
+ ...classifyDLPDetection(d, input),
412
+ };
413
+ if (feedbackStore && isLearnableDLPDetection(baseline)) {
410
414
  const start = d.position?.start ?? 0;
411
415
  const lineStart = input.lastIndexOf('\n', start) + 1;
412
416
  const lineEnd = input.indexOf('\n', start);
413
417
  const line = input.slice(lineStart, lineEnd === -1 ? undefined : lineEnd).trim();
414
418
  if (isLearnedAllowSync(feedbackStore, d.type, d.match, line)) {
415
419
  return {
416
- ...d,
420
+ ...baseline,
417
421
  confidence: 12,
418
422
  decision: 'allow',
419
423
  reason_codes: ['learned_false_positive'],
420
424
  };
421
425
  }
422
426
  }
423
- return {
424
- ...d,
425
- ...classifyDLPDetection(d, input),
426
- };
427
+ return baseline;
427
428
  });
428
429
  // Line-level learned expansion: one false-positive mark allows sibling patterns on same line
429
430
  const learnedStarts = new Set(classified
@@ -431,7 +432,10 @@ export function scanInputForCredentials(input, config = {}) {
431
432
  .map(d => d.position?.start ?? -1));
432
433
  const expanded = classified.map(d => {
433
434
  const start = d.position?.start ?? -1;
434
- if (start >= 0 && learnedStarts.has(start) && d.decision !== 'allow') {
435
+ if (start >= 0
436
+ && learnedStarts.has(start)
437
+ && d.decision !== 'allow'
438
+ && isLearnableDLPDetection(d)) {
435
439
  return {
436
440
  ...d,
437
441
  confidence: 12,
@@ -458,8 +462,10 @@ export function scanInputForCredentials(input, config = {}) {
458
462
  : 'warning';
459
463
  return {
460
464
  status,
461
- detections: deduped,
462
- ...(allowedDeduped.length > 0 ? { allowed_detections: allowedDeduped } : {}),
465
+ detections: deduped.map(toPublicDetection),
466
+ ...(allowedDeduped.length > 0
467
+ ? { allowed_detections: allowedDeduped.map(toPublicDetection) }
468
+ : {}),
463
469
  duration_ms: Date.now() - start,
464
470
  scanned_length: input.length,
465
471
  };
@@ -467,6 +473,14 @@ export function scanInputForCredentials(input, config = {}) {
467
473
  /**
468
474
  * Deduplicate overlapping detections — keep the higher severity one.
469
475
  */
476
+ function toPublicDetection(detection) {
477
+ const { match, ...safe } = detection;
478
+ return {
479
+ ...safe,
480
+ match: detection.redacted,
481
+ feedback_fingerprint: feedbackFingerprint(detection.type, match),
482
+ };
483
+ }
470
484
  function deduplicateDetections(detections) {
471
485
  if (detections.length <= 1)
472
486
  return detections;
@@ -6,8 +6,8 @@
6
6
  * InputValidationGate, and outputs JSON result to stdout.
7
7
  *
8
8
  * Exit codes:
9
- * 0 — clean (no credentials found)
10
- * 2 — blocked (credentials detected, transmission prevented)
9
+ * 0 — clean or warning (default)
10
+ * 2 — blocked when --block is explicitly set
11
11
  *
12
12
  * Usage:
13
13
  * echo "my api_key = sk-abc123..." | node standalone-dlp-checker.js
@@ -6,8 +6,8 @@
6
6
  * InputValidationGate, and outputs JSON result to stdout.
7
7
  *
8
8
  * Exit codes:
9
- * 0 — clean (no credentials found)
10
- * 2 — blocked (credentials detected, transmission prevented)
9
+ * 0 — clean or warning (default)
10
+ * 2 — blocked when --block is explicitly set
11
11
  *
12
12
  * Usage:
13
13
  * echo "my api_key = sk-abc123..." | node standalone-dlp-checker.js
@@ -20,7 +20,7 @@ import path from 'path';
20
20
  async function main() {
21
21
  const args = process.argv.slice(2);
22
22
  const isJson = args.includes('--json');
23
- const block = !args.includes('--warn-only');
23
+ const block = args.includes('--block');
24
24
  const agent = args.find(a => a.startsWith('--agent='))?.split('=')[1] || 'unknown';
25
25
  // Read stdin
26
26
  const chunks = [];
@@ -20,7 +20,10 @@ export interface ScoreEntry {
20
20
  }
21
21
  export interface ScoreTrend {
22
22
  direction: 'improving' | 'stable' | 'degrading';
23
+ /** Difference between recent and previous window averages. */
23
24
  delta: number;
25
+ /** Difference between the first and last score displayed to the user. */
26
+ visibleDelta: number;
24
27
  recentAvg: number;
25
28
  previousAvg: number;
26
29
  recentScores: number[];
@@ -48,6 +51,7 @@ export declare function recordScore(cwd: string, report: {
48
51
  export declare function getScoreHistory(cwd: string, limit?: number): ScoreEntry[];
49
52
  /**
50
53
  * Calculate score trend from history.
51
- * Compares average of last 5 runs vs previous 5 runs.
54
+ * Direction and delta describe the same recent scores shown to users.
55
+ * Window averages remain available for detailed historical comparison.
52
56
  */
53
57
  export declare function getScoreTrend(cwd: string): ScoreTrend | null;
@@ -65,7 +65,8 @@ export function getScoreHistory(cwd, limit = 20) {
65
65
  }
66
66
  /**
67
67
  * Calculate score trend from history.
68
- * Compares average of last 5 runs vs previous 5 runs.
68
+ * Direction and delta describe the same recent scores shown to users.
69
+ * Window averages remain available for detailed historical comparison.
69
70
  */
70
71
  export function getScoreTrend(cwd) {
71
72
  const history = getScoreHistory(cwd, 20);
@@ -75,27 +76,21 @@ export function getScoreTrend(cwd) {
75
76
  const recentScores = scores.slice(-5);
76
77
  const previousScores = scores.slice(-10, -5);
77
78
  const recentAvg = recentScores.reduce((a, b) => a + b, 0) / recentScores.length;
78
- if (previousScores.length === 0) {
79
- return {
80
- direction: 'stable',
81
- delta: 0,
82
- recentAvg: Math.round(recentAvg),
83
- previousAvg: Math.round(recentAvg),
84
- recentScores,
85
- };
86
- }
87
- const previousAvg = previousScores.reduce((a, b) => a + b, 0) / previousScores.length;
88
- const delta = recentAvg - previousAvg;
79
+ const previousAvg = previousScores.length > 0
80
+ ? previousScores.reduce((a, b) => a + b, 0) / previousScores.length
81
+ : recentAvg;
82
+ const windowDelta = recentAvg - previousAvg;
83
+ const visibleDelta = recentScores[recentScores.length - 1] - recentScores[0];
89
84
  // If all recent scores are the same (e.g. all 0 or all 100), trend is stable
90
85
  const allSame = recentScores.every(s => s === recentScores[0]);
91
86
  let direction;
92
- if (allSame && previousScores.length > 0 && previousScores.every(s => s === recentScores[0])) {
87
+ if (allSame) {
93
88
  direction = 'stable';
94
89
  }
95
- else if (delta > 3) {
90
+ else if (visibleDelta > 3) {
96
91
  direction = 'improving';
97
92
  }
98
- else if (delta < -3) {
93
+ else if (visibleDelta < -3) {
99
94
  direction = 'degrading';
100
95
  }
101
96
  else {
@@ -103,7 +98,8 @@ export function getScoreTrend(cwd) {
103
98
  }
104
99
  return {
105
100
  direction,
106
- delta: Math.round(delta * 10) / 10,
101
+ delta: Math.round(windowDelta * 10) / 10,
102
+ visibleDelta: Math.round(visibleDelta * 10) / 10,
107
103
  recentAvg: Math.round(recentAvg),
108
104
  previousAvg: Math.round(previousAvg),
109
105
  recentScores,
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,53 @@
1
+ import * as fs from 'fs';
2
+ import * as os from 'os';
3
+ import * as path from 'path';
4
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
5
+ import { getScoreTrend } from './score-history.js';
6
+ describe('getScoreTrend', () => {
7
+ let testDir;
8
+ beforeEach(() => {
9
+ testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'score-history-test-'));
10
+ fs.mkdirSync(path.join(testDir, '.rigour'));
11
+ });
12
+ afterEach(() => {
13
+ fs.rmSync(testDir, { recursive: true, force: true });
14
+ });
15
+ function writeScores(scores) {
16
+ const entries = scores.map((score, index) => ({
17
+ timestamp: new Date(index * 1000).toISOString(),
18
+ status: 'FAIL',
19
+ score,
20
+ failureCount: 1,
21
+ severity_breakdown: {},
22
+ provenance_breakdown: {},
23
+ }));
24
+ fs.writeFileSync(path.join(testDir, '.rigour', 'score-history.jsonl'), entries.map(entry => JSON.stringify(entry)).join('\n') + '\n');
25
+ }
26
+ it('labels the displayed rising scores as improving despite a higher older window', () => {
27
+ writeScores([90, 92, 94, 96, 98, 34, 29, 29, 52, 59]);
28
+ expect(getScoreTrend(testDir)).toMatchObject({
29
+ direction: 'improving',
30
+ delta: -53.4,
31
+ visibleDelta: 25,
32
+ recentScores: [34, 29, 29, 52, 59],
33
+ recentAvg: 41,
34
+ previousAvg: 94,
35
+ });
36
+ });
37
+ it('labels a falling displayed series as degrading', () => {
38
+ writeScores([80, 75, 70, 64, 59]);
39
+ expect(getScoreTrend(testDir)).toMatchObject({
40
+ direction: 'degrading',
41
+ delta: 0,
42
+ visibleDelta: -21,
43
+ });
44
+ });
45
+ it('treats small visible changes as stable', () => {
46
+ writeScores([50, 49, 52, 51, 53]);
47
+ expect(getScoreTrend(testDir)).toMatchObject({
48
+ direction: 'stable',
49
+ delta: 0,
50
+ visibleDelta: 3,
51
+ });
52
+ });
53
+ });
@@ -215,7 +215,7 @@ export const UNIVERSAL_CONFIG = {
215
215
  },
216
216
  input_validation: {
217
217
  enabled: true,
218
- block_on_detection: true,
218
+ block_on_detection: false,
219
219
  min_secret_length: 8,
220
220
  custom_patterns: [],
221
221
  ignore_patterns: [],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rigour-labs/core",
3
- "version": "5.5.0",
3
+ "version": "5.5.1",
4
4
  "description": "AI-native quality gate engine with local Bayesian learning. AST analysis, drift detection, Fix Packet generation, and agent self-healing across TypeScript, JavaScript, Python, Go, Ruby, and C#.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://rigour.run",
@@ -66,11 +66,11 @@
66
66
  "@xenova/transformers": "^2.17.2",
67
67
  "sqlite3": "^5.1.7",
68
68
  "openai": "^4.104.0",
69
- "@rigour-labs/brain-darwin-arm64": "5.5.0",
70
- "@rigour-labs/brain-darwin-x64": "5.5.0",
71
- "@rigour-labs/brain-linux-x64": "5.5.0",
72
- "@rigour-labs/brain-win-x64": "5.5.0",
73
- "@rigour-labs/brain-linux-arm64": "5.5.0"
69
+ "@rigour-labs/brain-darwin-x64": "5.5.1",
70
+ "@rigour-labs/brain-linux-arm64": "5.5.1",
71
+ "@rigour-labs/brain-darwin-arm64": "5.5.1",
72
+ "@rigour-labs/brain-win-x64": "5.5.1",
73
+ "@rigour-labs/brain-linux-x64": "5.5.1"
74
74
  },
75
75
  "devDependencies": {
76
76
  "@types/fs-extra": "^11.0.4",