@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.
- package/dist/gates/hallucinated-imports/js-resolver.js +8 -15
- package/dist/gates/hallucinated-imports/ts-path-target.d.ts +1 -0
- package/dist/gates/hallucinated-imports/ts-path-target.js +27 -0
- package/dist/gates/hallucinated-imports.test.js +37 -1
- package/dist/hooks/dlp-feedback.d.ts +11 -5
- package/dist/hooks/dlp-feedback.js +61 -10
- package/dist/hooks/dlp-feedback.test.js +93 -1
- package/dist/hooks/dlp-templates.d.ts +2 -2
- package/dist/hooks/dlp-templates.js +13 -16
- package/dist/hooks/input-validator.d.ts +2 -0
- package/dist/hooks/input-validator.js +25 -11
- package/dist/hooks/standalone-dlp-checker.d.ts +2 -2
- package/dist/hooks/standalone-dlp-checker.js +3 -3
- package/dist/services/score-history.d.ts +5 -1
- package/dist/services/score-history.js +12 -16
- package/dist/services/score-history.test.d.ts +1 -0
- package/dist/services/score-history.test.js +53 -0
- package/dist/templates/universal-config.js +1 -1
- package/package.json +6 -6
|
@@ -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
|
-
|
|
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
|
|
46
|
+
match?: string;
|
|
47
|
+
feedback_fingerprint?: string;
|
|
48
|
+
confidence?: number;
|
|
43
49
|
position?: {
|
|
44
50
|
start: number;
|
|
45
51
|
end: number;
|
|
46
52
|
};
|
|
47
|
-
}>,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
142
|
-
await fs.writeJson(
|
|
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
|
-
|
|
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:
|
|
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, {
|
|
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
|
|
2
|
+
* DLP Hook Templates — Pre-Input Credential Warnings
|
|
3
3
|
*
|
|
4
4
|
* Generates tool-native hook configs that intercept user input
|
|
5
|
-
*
|
|
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
|
|
2
|
+
* DLP Hook Templates — Pre-Input Credential Warnings
|
|
3
3
|
*
|
|
4
4
|
* Generates tool-native hook configs that intercept user input
|
|
5
|
-
*
|
|
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
|
-
//
|
|
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
|
|
106
|
-
process.stderr.write('[rigour/dlp] ' + result.detections.length + ' credential(s)
|
|
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(' [
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
250
|
+
if (result.status !== 'clean') {
|
|
253
251
|
for (const d of result.detections) {
|
|
254
|
-
process.stderr.write('[rigour/dlp]
|
|
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
|
|
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?:\/\/[
|
|
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
|
-
|
|
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
|
-
...
|
|
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
|
|
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
|
|
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
|
|
10
|
-
* 2 — blocked
|
|
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
|
|
10
|
-
* 2 — blocked
|
|
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 =
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
87
|
+
if (allSame) {
|
|
93
88
|
direction = 'stable';
|
|
94
89
|
}
|
|
95
|
-
else if (
|
|
90
|
+
else if (visibleDelta > 3) {
|
|
96
91
|
direction = 'improving';
|
|
97
92
|
}
|
|
98
|
-
else if (
|
|
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(
|
|
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
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rigour-labs/core",
|
|
3
|
-
"version": "5.5.
|
|
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-
|
|
70
|
-
"@rigour-labs/brain-
|
|
71
|
-
"@rigour-labs/brain-
|
|
72
|
-
"@rigour-labs/brain-win-x64": "5.5.
|
|
73
|
-
"@rigour-labs/brain-linux-
|
|
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",
|