@rigour-labs/core 4.3.2 → 4.3.4

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.
@@ -36,9 +36,10 @@ export class ContextWindowArtifactsGate extends Gate {
36
36
  if (!this.config.enabled)
37
37
  return [];
38
38
  const failures = [];
39
+ const scanPatterns = context.patterns || ['**/*.{ts,js,tsx,jsx,py}'];
39
40
  const files = await FileScanner.findFiles({
40
41
  cwd: context.cwd,
41
- patterns: ['**/*.{ts,js,tsx,jsx,py}'],
42
+ patterns: scanPatterns,
42
43
  ignore: [...(context.ignore || []), '**/node_modules/**', '**/dist/**', '**/*.test.*', '**/*.spec.*', '**/*.min.*'],
43
44
  });
44
45
  Logger.info(`Context Window Artifacts: Scanning ${files.length} files`);
@@ -48,9 +48,11 @@ export class DeprecatedApisGate extends Gate {
48
48
  return [];
49
49
  const failures = [];
50
50
  const deprecated = [];
51
+ const defaultPatterns = ['**/*.{ts,js,tsx,jsx,py,go,cs,java,kt}'];
52
+ const scanPatterns = context.patterns || defaultPatterns;
51
53
  const files = await FileScanner.findFiles({
52
54
  cwd: context.cwd,
53
- patterns: ['**/*.{ts,js,tsx,jsx,py,go,cs,java,kt}'],
55
+ patterns: scanPatterns,
54
56
  ignore: [...(context.ignore || []), '**/node_modules/**', '**/dist/**', '**/build/**',
55
57
  '**/*.test.*', '**/*.spec.*', '**/__tests__/**',
56
58
  '**/.venv/**', '**/venv/**', '**/vendor/**', '**/__pycache__/**',
@@ -33,9 +33,10 @@ export class DuplicationDriftGate extends Gate {
33
33
  return [];
34
34
  const failures = [];
35
35
  const functions = [];
36
+ const scanPatterns = context.patterns || ['**/*.{ts,js,tsx,jsx,py}'];
36
37
  const files = await FileScanner.findFiles({
37
38
  cwd: context.cwd,
38
- patterns: ['**/*.{ts,js,tsx,jsx,py}'],
39
+ patterns: scanPatterns,
39
40
  ignore: [...(context.ignore || []), '**/node_modules/**', '**/dist/**', '**/*.test.*', '**/*.spec.*'],
40
41
  });
41
42
  Logger.info(`Duplication Drift: Scanning ${files.length} files`);
@@ -1,3 +1,22 @@
1
+ /**
2
+ * Frontend Secret Exposure Gate
3
+ *
4
+ * Detects server-side secret keys (Stripe, OpenAI, AWS, etc.) referenced
5
+ * in frontend-accessible files where they would be bundled into client JS.
6
+ *
7
+ * The "vibe-coded startup" attack pattern:
8
+ * process.env.STRIPE_SECRET_KEY inside a React component
9
+ * → bundled by webpack/Vite → visible in DevTools → $2,500 in fraudulent charges
10
+ *
11
+ * Two detection modes:
12
+ * 1. Env-var name detection: process.env.VARNAME / import.meta.env.VARNAME
13
+ * where VARNAME matches secret_env_name_patterns and lacks a safe public prefix.
14
+ * 2. Literal key detection: actual live API key values embedded in source
15
+ * (sk_live_..., sk-proj-..., AKIA..., ghp_..., etc.)
16
+ *
17
+ * @since v4.4.0
18
+ * @see CWE-312 Cleartext Storage of Sensitive Information
19
+ */
1
20
  import { Gate, GateContext } from './base.js';
2
21
  import { Failure, Provenance } from '../types/index.js';
3
22
  export interface FrontendSecretExposureConfig {
@@ -12,16 +31,29 @@ export interface FrontendSecretExposureConfig {
12
31
  allowlist_env_names?: string[];
13
32
  }
14
33
  export declare class FrontendSecretExposureGate extends Gate {
15
- private config;
16
- private severityOrder;
34
+ private cfg;
35
+ private secretNamePatterns;
36
+ private safePrefixPatterns;
37
+ private frontendPathPatterns;
38
+ private serverPathPatterns;
17
39
  constructor(config?: FrontendSecretExposureConfig);
18
40
  protected get provenance(): Provenance;
19
41
  run(context: GateContext): Promise<Failure[]>;
20
- private shouldSkipFile;
21
- private isClientBundled;
22
- private isServerOnlyContent;
23
- private findEnvExposures;
24
- private collectMatches;
25
- private isSecretLikeEnvName;
26
- private matchesAnyPattern;
42
+ /** Classify a file path as frontend, server, or ambiguous based on config patterns. */
43
+ private classifyFile;
44
+ /** Test/fixture files are excluded — they legitimately contain dummy keys. */
45
+ private isTestFile;
46
+ /**
47
+ * Check whether a var name looks like a server-side secret:
48
+ * - Matches at least one secret_env_name_pattern
49
+ * - Does NOT start with a safe public prefix
50
+ * - Is NOT in the user allowlist
51
+ */
52
+ private isSecretVar;
53
+ /** Scan one file for env-var references and literal key values. */
54
+ private scanFile;
55
+ private makeEnvRefExposure;
56
+ /** Filters out trivial dummy/placeholder text to reduce false positives. */
57
+ private isDummyValue;
58
+ private toFailures;
27
59
  }
@@ -1,174 +1,354 @@
1
+ /**
2
+ * Frontend Secret Exposure Gate
3
+ *
4
+ * Detects server-side secret keys (Stripe, OpenAI, AWS, etc.) referenced
5
+ * in frontend-accessible files where they would be bundled into client JS.
6
+ *
7
+ * The "vibe-coded startup" attack pattern:
8
+ * process.env.STRIPE_SECRET_KEY inside a React component
9
+ * → bundled by webpack/Vite → visible in DevTools → $2,500 in fraudulent charges
10
+ *
11
+ * Two detection modes:
12
+ * 1. Env-var name detection: process.env.VARNAME / import.meta.env.VARNAME
13
+ * where VARNAME matches secret_env_name_patterns and lacks a safe public prefix.
14
+ * 2. Literal key detection: actual live API key values embedded in source
15
+ * (sk_live_..., sk-proj-..., AKIA..., ghp_..., etc.)
16
+ *
17
+ * @since v4.4.0
18
+ * @see CWE-312 Cleartext Storage of Sensitive Information
19
+ */
1
20
  import { Gate } from './base.js';
2
21
  import { FileScanner } from '../utils/scanner.js';
3
22
  import { Logger } from '../utils/logger.js';
4
23
  import fs from 'fs-extra';
5
24
  import path from 'path';
6
- const DEFAULT_SECRET_ENV_NAME_PATTERNS = [
7
- '(?:^|_)(?:secret|private)(?:_|$)',
8
- '(?:^|_)(?:token|api[_-]?key|access[_-]?key|client[_-]?secret|signing|webhook)(?:_|$)',
9
- '(?:^|_)(?:db[_-]?url|database[_-]?url|connection[_-]?string)(?:_|$)',
10
- ];
11
- const DEFAULT_SAFE_PUBLIC_PREFIXES = [
12
- 'NEXT_PUBLIC_',
13
- 'VITE_',
14
- 'PUBLIC_',
15
- 'NUXT_PUBLIC_',
16
- 'REACT_APP_',
17
- ];
18
- const DEFAULT_FRONTEND_PATH_PATTERNS = [
19
- '(^|/)pages/(?!api/)',
20
- '(^|/)components/',
21
- '(^|/)src/components/',
22
- '(^|/)src/views/',
23
- '(^|/)src/app/',
24
- '(^|/)app/(?!api/)',
25
- '(^|/)views/',
26
- '(^|/)public/',
27
- ];
28
- const DEFAULT_SERVER_PATH_PATTERNS = [
29
- '(^|/)pages/api/',
30
- '(^|/)src/pages/api/',
31
- '(^|/)app/api/',
32
- '(^|/)src/app/api/',
33
- '\\.server\\.(?:ts|tsx|js|jsx|mjs|cjs)$',
25
+ /**
26
+ * Literal API key patterns — actual values that are always dangerous in frontend code.
27
+ * These bypass the env-name heuristic and fire regardless of variable naming.
28
+ */
29
+ const LITERAL_KEY_PATTERNS = [
30
+ {
31
+ label: 'Stripe Live Secret Key',
32
+ regex: /\bsk_live_[a-zA-Z0-9]{24,}\b/g,
33
+ severity: 'critical',
34
+ hint: 'Live Stripe secret key (sk_live_...) exposed in client bundle. ' +
35
+ 'Attackers can charge cards without a frontend. Move to server-side only.',
36
+ cwe: 'CWE-312',
37
+ },
38
+ {
39
+ label: 'Stripe Live Restricted Key',
40
+ regex: /\brk_live_[a-zA-Z0-9]{24,}\b/g,
41
+ severity: 'critical',
42
+ hint: 'Live Stripe restricted key (rk_live_...) must never appear in frontend code.',
43
+ cwe: 'CWE-312',
44
+ },
45
+ {
46
+ label: 'OpenAI API Key',
47
+ regex: /\bsk-proj-[a-zA-Z0-9_-]{40,}\b/g,
48
+ severity: 'critical',
49
+ hint: 'OpenAI project API key (sk-proj-...) exposed in client bundle. ' +
50
+ 'Attackers can make unlimited API calls at your expense.',
51
+ cwe: 'CWE-312',
52
+ },
53
+ {
54
+ label: 'OpenAI Legacy API Key',
55
+ regex: /\bsk-[a-zA-Z0-9]{48,}\b/g,
56
+ severity: 'critical',
57
+ hint: 'OpenAI API key (sk-...) exposed in client bundle. Move to server-side proxy.',
58
+ cwe: 'CWE-312',
59
+ },
60
+ {
61
+ label: 'AWS Access Key ID',
62
+ regex: /\bAKIA[A-Z2-7]{16}\b/g,
63
+ severity: 'critical',
64
+ hint: 'AWS Access Key ID exposed in client bundle. This grants AWS API access.',
65
+ cwe: 'CWE-312',
66
+ },
67
+ {
68
+ label: 'GitHub Personal Access Token',
69
+ regex: /\bghp_[a-zA-Z0-9]{36,}\b/g,
70
+ severity: 'critical',
71
+ hint: 'GitHub PAT (ghp_...) in frontend code. Grants repo access to anyone who views bundle.',
72
+ cwe: 'CWE-312',
73
+ },
74
+ {
75
+ label: 'GitHub Fine-Grained PAT',
76
+ regex: /\bgithub_pat_[a-zA-Z0-9_]{55,}\b/g,
77
+ severity: 'critical',
78
+ hint: 'GitHub fine-grained PAT (github_pat_...) must never appear in frontend code.',
79
+ cwe: 'CWE-312',
80
+ },
81
+ {
82
+ label: 'Anthropic API Key',
83
+ regex: /\bsk-ant-api\d{2}-[a-zA-Z0-9_-]{90,}\b/g,
84
+ severity: 'critical',
85
+ hint: 'Anthropic API key exposed in client bundle. Move to a server-side proxy.',
86
+ cwe: 'CWE-312',
87
+ },
88
+ {
89
+ label: 'SendGrid API Key',
90
+ regex: /\bSG\.[a-zA-Z0-9_-]{22,}\.[a-zA-Z0-9_-]{43,}\b/g,
91
+ severity: 'critical',
92
+ hint: 'SendGrid API key exposed in client bundle. Attackers can send spam as you.',
93
+ cwe: 'CWE-312',
94
+ },
34
95
  ];
96
+ /** Severity ordering for threshold comparison */
97
+ const SEVERITY_ORDER = { critical: 0, high: 1, medium: 2, low: 3 };
98
+ /**
99
+ * Extracts the env var name from a single-reference line.
100
+ * process.env.VARNAME → "VARNAME"
101
+ * process.env["VARNAME"] → "VARNAME"
102
+ * import.meta.env.VARNAME → "VARNAME"
103
+ * Returns null if no match.
104
+ */
105
+ function extractEnvVarName(line, checkProcessEnv, checkImportMetaEnv) {
106
+ if (checkProcessEnv) {
107
+ const dot = line.match(/process\.env\.([A-Z][A-Z0-9_]*)/);
108
+ if (dot)
109
+ return dot[1];
110
+ const bracket = line.match(/process\.env\[['"]([A-Z][A-Z0-9_]*)['"]\]/);
111
+ if (bracket)
112
+ return bracket[1];
113
+ }
114
+ if (checkImportMetaEnv) {
115
+ const vite = line.match(/import\.meta\.env\.([A-Z_][A-Z0-9_]*)/);
116
+ if (vite)
117
+ return vite[1];
118
+ }
119
+ return null;
120
+ }
121
+ /**
122
+ * Finds all var names from destructuring:
123
+ * const { STRIPE_SECRET_KEY, OPENAI_KEY } = process.env
124
+ */
125
+ function extractDestructuredEnvVars(line) {
126
+ if (!/=\s*process\.env\b/.test(line))
127
+ return [];
128
+ const brace = line.match(/\{\s*([^}]+)\s*\}/);
129
+ if (!brace)
130
+ return [];
131
+ return brace[1]
132
+ .split(',')
133
+ .map(s => s.split(':')[0].trim()) // handle "SECRET_KEY: renamed"
134
+ .filter(s => /^[A-Z_][A-Z0-9_]*$/.test(s));
135
+ }
35
136
  export class FrontendSecretExposureGate extends Gate {
36
- config;
37
- severityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
137
+ cfg;
138
+ secretNamePatterns;
139
+ safePrefixPatterns;
140
+ frontendPathPatterns;
141
+ serverPathPatterns;
38
142
  constructor(config = {}) {
39
- super('frontend-secret-exposure', 'Frontend Secret Exposure Detection');
40
- this.config = {
143
+ super('frontend-secret-exposure', 'Frontend Secret Exposure');
144
+ this.cfg = {
41
145
  enabled: config.enabled ?? true,
42
146
  block_on_severity: config.block_on_severity ?? 'high',
43
147
  check_process_env: config.check_process_env ?? true,
44
148
  check_import_meta_env: config.check_import_meta_env ?? true,
45
- secret_env_name_patterns: config.secret_env_name_patterns ?? DEFAULT_SECRET_ENV_NAME_PATTERNS,
46
- safe_public_prefixes: config.safe_public_prefixes ?? DEFAULT_SAFE_PUBLIC_PREFIXES,
47
- frontend_path_patterns: config.frontend_path_patterns ?? DEFAULT_FRONTEND_PATH_PATTERNS,
48
- server_path_patterns: config.server_path_patterns ?? DEFAULT_SERVER_PATH_PATTERNS,
149
+ secret_env_name_patterns: config.secret_env_name_patterns ?? [
150
+ '(?:^|_)(?:secret|private)(?:_|$)',
151
+ '(?:^|_)(?:token|api[_-]?key|access[_-]?key|client[_-]?secret|signing|webhook)(?:_|$)',
152
+ '(?:^|_)(?:db[_-]?url|database[_-]?url|connection[_-]?string)(?:_|$)',
153
+ ],
154
+ safe_public_prefixes: config.safe_public_prefixes ?? [
155
+ 'NEXT_PUBLIC_', 'VITE_', 'PUBLIC_', 'NUXT_PUBLIC_', 'REACT_APP_',
156
+ ],
157
+ frontend_path_patterns: config.frontend_path_patterns ?? [
158
+ '(^|/)pages/(?!api/)',
159
+ '(^|/)components/',
160
+ '(^|/)src/components/',
161
+ '(^|/)src/views/',
162
+ '(^|/)src/app/',
163
+ '(^|/)app/(?!api/)',
164
+ '(^|/)views/',
165
+ '(^|/)public/',
166
+ ],
167
+ server_path_patterns: config.server_path_patterns ?? [
168
+ '(^|/)pages/api/',
169
+ '(^|/)src/pages/api/',
170
+ '(^|/)app/api/',
171
+ '(^|/)src/app/api/',
172
+ '\\.server\\.(?:ts|tsx|js|jsx|mjs|cjs)$',
173
+ ],
49
174
  allowlist_env_names: config.allowlist_env_names ?? [],
50
175
  };
176
+ this.secretNamePatterns = this.cfg.secret_env_name_patterns.map(p => new RegExp(p, 'i'));
177
+ // Escape prefix strings to safe regex literals
178
+ this.safePrefixPatterns = this.cfg.safe_public_prefixes.map(prefix => new RegExp(`^${prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`));
179
+ this.frontendPathPatterns = this.cfg.frontend_path_patterns.map(p => new RegExp(p));
180
+ this.serverPathPatterns = this.cfg.server_path_patterns.map(p => new RegExp(p));
51
181
  }
52
182
  get provenance() { return 'security'; }
53
183
  async run(context) {
54
- if (!this.config.enabled)
184
+ if (!this.cfg.enabled)
55
185
  return [];
186
+ const scanPatterns = context.patterns || ['**/*.{ts,tsx,js,jsx,mjs,cjs,vue,svelte}'];
56
187
  const files = await FileScanner.findFiles({
57
188
  cwd: context.cwd,
58
- patterns: ['**/*.{ts,tsx,js,jsx,mjs,cjs}'],
59
- ignore: [...(context.ignore || []), '**/node_modules/**', '**/dist/**', '**/build/**', '**/.next/**', '**/coverage/**'],
189
+ patterns: scanPatterns,
190
+ ignore: [
191
+ ...(context.ignore || []),
192
+ '**/node_modules/**', '**/dist/**', '**/build/**',
193
+ '**/.next/**', '**/coverage/**', '**/out/**',
194
+ ],
60
195
  });
61
- const scanFiles = files.filter(file => !this.shouldSkipFile(file));
62
- const findings = [];
63
- Logger.info(`Frontend Secret Exposure Gate: Scanning ${scanFiles.length} files`);
64
- for (const file of scanFiles) {
65
- const fullPath = path.join(context.cwd, file);
66
- let content = '';
196
+ // Skip test/fixture files they routinely use dummy keys
197
+ const sourceFiles = files.filter(f => !this.isTestFile(f));
198
+ Logger.info(`Frontend Secret Exposure Gate: scanning ${sourceFiles.length} files`);
199
+ const exposures = [];
200
+ for (const file of sourceFiles) {
201
+ const fileContext = this.classifyFile(file);
202
+ if (fileContext === 'server')
203
+ continue;
67
204
  try {
68
- content = await fs.readFile(fullPath, 'utf-8');
205
+ const fullPath = path.join(context.cwd, file);
206
+ const content = await fs.readFile(fullPath, 'utf-8');
207
+ // Skip files guarded by Next.js server-only import
208
+ if (/import\s+['"]server-only['"]/.test(content))
209
+ continue;
210
+ this.scanFile(content, file, fileContext, exposures);
69
211
  }
70
212
  catch {
71
- continue;
213
+ // Unreadable — skip silently
72
214
  }
73
- if (!this.isClientBundled(file, content))
74
- continue;
75
- findings.push(...this.findEnvExposures(file, content));
76
215
  }
77
- findings.sort((a, b) => this.severityOrder[a.severity] - this.severityOrder[b.severity]);
78
- const threshold = this.severityOrder[this.config.block_on_severity];
79
- return findings
80
- .filter(f => this.severityOrder[f.severity] <= threshold)
81
- .map(f => this.createFailure(`Potential frontend secret exposure: ${f.source}.${f.envVar} is referenced in client-bundled code.`, [f.file], 'Move secret usage to server-only code (API route/server action) and expose only public-safe values.', 'Security: Frontend Secret Exposure', f.line, f.line, f.severity));
216
+ return this.toFailures(exposures);
82
217
  }
83
- shouldSkipFile(file) {
84
- const normalized = file.replace(/\\/g, '/');
85
- if (/\.(test|spec)\.(?:ts|tsx|js|jsx|mjs|cjs)$/i.test(normalized))
86
- return true;
87
- if (/\/(?:__tests__|tests|test|__test__|e2e|fixtures|mocks)\//.test(`/${normalized}`))
88
- return true;
89
- if (/\/(?:examples|studio-dist)\//.test(`/${normalized}`))
90
- return true;
91
- return false;
218
+ /** Classify a file path as frontend, server, or ambiguous based on config patterns. */
219
+ classifyFile(file) {
220
+ const n = file.replace(/\\/g, '/');
221
+ // Server paths take priority over frontend paths
222
+ if (this.serverPathPatterns.some(p => p.test(n)))
223
+ return 'server';
224
+ if (this.frontendPathPatterns.some(p => p.test(n)))
225
+ return 'frontend';
226
+ return 'ambiguous';
227
+ }
228
+ /** Test/fixture files are excluded — they legitimately contain dummy keys. */
229
+ isTestFile(file) {
230
+ const n = file.replace(/\\/g, '/');
231
+ return /\.(test|spec)\.(ts|tsx|js|jsx)$/.test(n)
232
+ || /\/__tests__\//.test(n)
233
+ || /\/(?:fixtures|mocks|stubs)\//.test(n);
92
234
  }
93
- isClientBundled(file, content) {
94
- const normalized = file.replace(/\\/g, '/');
95
- if (this.matchesAnyPattern(normalized, this.config.server_path_patterns))
235
+ /**
236
+ * Check whether a var name looks like a server-side secret:
237
+ * - Matches at least one secret_env_name_pattern
238
+ * - Does NOT start with a safe public prefix
239
+ * - Is NOT in the user allowlist
240
+ */
241
+ isSecretVar(varName) {
242
+ if (this.cfg.allowlist_env_names.includes(varName))
96
243
  return false;
97
- if (this.isServerOnlyContent(content))
244
+ if (this.safePrefixPatterns.some(p => p.test(varName)))
98
245
  return false;
99
- if (/^\s*['"]use client['"]\s*;?/m.test(content))
100
- return true;
101
- if (this.matchesAnyPattern(normalized, this.config.frontend_path_patterns))
102
- return true;
103
- return false;
104
- }
105
- isServerOnlyContent(content) {
106
- if (/from\s+['"]server-only['"]/.test(content))
107
- return true;
108
- if (/import\s+['"]server-only['"]/.test(content))
109
- return true;
110
- if (/export\s+async\s+function\s+getServerSideProps\s*\(/.test(content))
111
- return true;
112
- if (/export\s+async\s+function\s+getStaticProps\s*\(/.test(content))
113
- return true;
114
- if (/['"]use server['"]/.test(content))
115
- return true;
116
- return false;
117
- }
118
- findEnvExposures(file, content) {
119
- const findings = [];
120
- if (this.config.check_process_env) {
121
- const processEnvRegex = /process\.env\.([A-Za-z_][A-Za-z0-9_]*)/g;
122
- findings.push(...this.collectMatches(file, content, processEnvRegex, 'process.env'));
123
- }
124
- if (this.config.check_import_meta_env) {
125
- const importMetaRegex = /import\.meta\.env\.([A-Za-z_][A-Za-z0-9_]*)/g;
126
- findings.push(...this.collectMatches(file, content, importMetaRegex, 'import.meta.env'));
127
- }
128
- return findings;
246
+ return this.secretNamePatterns.some(p => p.test(varName));
129
247
  }
130
- collectMatches(file, content, regex, source) {
131
- const matches = [];
132
- const scanRegex = new RegExp(regex.source, 'g');
133
- for (const match of content.matchAll(scanRegex)) {
134
- const envVar = match[1];
135
- if (!this.isSecretLikeEnvName(envVar))
248
+ /** Scan one file for env-var references and literal key values. */
249
+ scanFile(content, file, fileContext, out) {
250
+ const lines = content.split('\n');
251
+ for (let i = 0; i < lines.length; i++) {
252
+ const line = lines[i];
253
+ const lineNum = i + 1;
254
+ // Skip comment lines to avoid flagging docs/examples
255
+ const trimmed = line.trimStart();
256
+ if (trimmed.startsWith('//') ||
257
+ trimmed.startsWith('*') ||
258
+ trimmed.startsWith('#') ||
259
+ trimmed.startsWith('<!--'))
136
260
  continue;
137
- const startIndex = match.index ?? 0;
138
- const beforeMatch = content.slice(0, startIndex);
139
- const line = beforeMatch.split('\n').length;
140
- matches.push({
141
- file,
142
- line,
143
- envVar,
144
- source,
145
- severity: 'high',
146
- });
147
- }
148
- return matches;
149
- }
150
- isSecretLikeEnvName(envVar) {
151
- if (this.config.allowlist_env_names.includes(envVar))
152
- return false;
153
- if (this.config.safe_public_prefixes.some(prefix => envVar.startsWith(prefix)))
154
- return false;
155
- return this.config.secret_env_name_patterns.some(pattern => {
156
- try {
157
- return new RegExp(pattern, 'i').test(envVar);
261
+ // 1. Env-var name detection (process.env / import.meta.env)
262
+ if (this.cfg.check_process_env || this.cfg.check_import_meta_env) {
263
+ const single = extractEnvVarName(line, this.cfg.check_process_env, this.cfg.check_import_meta_env);
264
+ if (single && this.isSecretVar(single)) {
265
+ out.push(this.makeEnvRefExposure(file, lineNum, line, single, fileContext));
266
+ }
267
+ // Destructuring: const { STRIPE_SECRET_KEY } = process.env
268
+ if (this.cfg.check_process_env) {
269
+ for (const varName of extractDestructuredEnvVars(line)) {
270
+ if (this.isSecretVar(varName)) {
271
+ out.push(this.makeEnvRefExposure(file, lineNum, line, varName, fileContext));
272
+ }
273
+ }
274
+ }
158
275
  }
159
- catch {
160
- return false;
276
+ // 2. Literal API key detection
277
+ for (const pattern of LITERAL_KEY_PATTERNS) {
278
+ pattern.regex.lastIndex = 0;
279
+ let m;
280
+ while ((m = pattern.regex.exec(line)) !== null) {
281
+ // Skip dummy/placeholder values and test-mode keys
282
+ if (this.isDummyValue(m[0]))
283
+ continue;
284
+ if (/(?:sk_test_|pk_test_|_test_|_sandbox_)/i.test(m[0]))
285
+ continue;
286
+ out.push({
287
+ file,
288
+ line: lineNum,
289
+ lineText: line.trim().slice(0, 80),
290
+ varName: m[0].slice(0, 24) + (m[0].length > 24 ? '...' : ''),
291
+ kind: 'literal',
292
+ serviceLabel: pattern.label,
293
+ hint: pattern.hint,
294
+ cwe: pattern.cwe,
295
+ severity: pattern.severity,
296
+ fileContext,
297
+ });
298
+ }
161
299
  }
162
- });
300
+ }
163
301
  }
164
- matchesAnyPattern(value, patterns) {
165
- return patterns.some(pattern => {
166
- try {
167
- return new RegExp(pattern, 'i').test(value);
168
- }
169
- catch {
302
+ makeEnvRefExposure(file, line, lineText, varName, fileContext) {
303
+ // Definite frontend → critical; ambiguous → high (may be a shared util)
304
+ const severity = fileContext === 'frontend' ? 'critical' : 'high';
305
+ return {
306
+ file,
307
+ line,
308
+ lineText: lineText.trim().slice(0, 80),
309
+ varName,
310
+ kind: 'env-ref',
311
+ serviceLabel: 'Secret Env Var',
312
+ hint: `\`${varName}\` looks like a server-side secret but is referenced in a ` +
313
+ `client-bundled file. Use an API route (Next.js /api, Remix action, ` +
314
+ `Nuxt server route) to proxy calls instead of exposing the secret to the browser.`,
315
+ cwe: 'CWE-312',
316
+ severity,
317
+ fileContext,
318
+ };
319
+ }
320
+ /** Filters out trivial dummy/placeholder text to reduce false positives. */
321
+ isDummyValue(text) {
322
+ return /(?:example|placeholder|your[_-]|xxx+|dummy|fake|changeme)/i.test(text);
323
+ }
324
+ toFailures(exposures) {
325
+ if (exposures.length === 0)
326
+ return [];
327
+ const blockThreshold = SEVERITY_ORDER[this.cfg.block_on_severity];
328
+ // Deduplicate: same file + line + varName from parallel patterns
329
+ const seen = new Set();
330
+ const unique = exposures.filter(e => {
331
+ const key = `${e.file}:${e.line}:${e.varName}`;
332
+ if (seen.has(key))
170
333
  return false;
171
- }
334
+ seen.add(key);
335
+ return true;
172
336
  });
337
+ // Sort critical first
338
+ unique.sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]);
339
+ const failures = [];
340
+ for (const exp of unique) {
341
+ if (SEVERITY_ORDER[exp.severity] > blockThreshold)
342
+ continue;
343
+ const ctx = exp.fileContext === 'frontend' ? '[definite frontend]' : '[possible frontend]';
344
+ const kind = exp.kind === 'literal' ? 'literal key' : 'env var ref';
345
+ failures.push(this.createFailure(`[${exp.cwe}] ${exp.serviceLabel} (${kind}) in ${ctx} file — ` +
346
+ `\`${exp.varName}\` at line ${exp.line}`, [exp.file], exp.hint, `Security: Frontend Secret Exposure — ${exp.serviceLabel}`, exp.line, exp.line, exp.severity));
347
+ }
348
+ if (unique.length > 0 && failures.length === 0) {
349
+ Logger.info(`Frontend Secret Exposure: ${unique.length} issue(s) below ` +
350
+ `${this.cfg.block_on_severity} threshold`);
351
+ }
352
+ return failures;
173
353
  }
174
354
  }
@@ -47,9 +47,11 @@ export class HallucinatedImportsGate extends Gate {
47
47
  return [];
48
48
  const failures = [];
49
49
  const hallucinated = [];
50
+ const defaultPatterns = ['**/*.{ts,js,tsx,jsx,py,go,rb,cs,rs,java,kt}'];
51
+ const scanPatterns = context.patterns || defaultPatterns;
50
52
  const files = await FileScanner.findFiles({
51
53
  cwd: context.cwd,
52
- patterns: ['**/*.{ts,js,tsx,jsx,py,go,rb,cs,rs,java,kt}'],
54
+ patterns: scanPatterns,
53
55
  ignore: [...(context.ignore || []), '**/node_modules/**', '**/dist/**', '**/build/**',
54
56
  '**/examples/**',
55
57
  '**/studio-dist/**', '**/.next/**', '**/coverage/**',
@@ -41,9 +41,10 @@ export class InconsistentErrorHandlingGate extends Gate {
41
41
  return [];
42
42
  const failures = [];
43
43
  const handlers = [];
44
+ const scanPatterns = context.patterns || ['**/*.{ts,js,tsx,jsx}'];
44
45
  const files = await FileScanner.findFiles({
45
46
  cwd: context.cwd,
46
- patterns: ['**/*.{ts,js,tsx,jsx}'],
47
+ patterns: scanPatterns,
47
48
  ignore: [...(context.ignore || []), '**/node_modules/**', '**/dist/**', '**/*.test.*', '**/*.spec.*'],
48
49
  });
49
50
  Logger.info(`Inconsistent Error Handling: Scanning ${files.length} files`);
@@ -45,9 +45,11 @@ export class PhantomApisGate extends Gate {
45
45
  return [];
46
46
  const failures = [];
47
47
  const phantoms = [];
48
+ const defaultPatterns = ['**/*.{ts,js,tsx,jsx,py,go,cs,java,kt}'];
49
+ const scanPatterns = context.patterns || defaultPatterns;
48
50
  const files = await FileScanner.findFiles({
49
51
  cwd: context.cwd,
50
- patterns: ['**/*.{ts,js,tsx,jsx,py,go,cs,java,kt}'],
52
+ patterns: scanPatterns,
51
53
  ignore: [...(context.ignore || []), '**/node_modules/**', '**/dist/**', '**/build/**',
52
54
  '**/*.test.*', '**/*.spec.*', '**/__tests__/**',
53
55
  '**/.venv/**', '**/venv/**', '**/vendor/**', '**/__pycache__/**',
@@ -39,7 +39,7 @@ export function extractIndentedBody(content, startIdx) {
39
39
  }
40
40
  export function isInsideTryBlock(lines, lineIdx) {
41
41
  let braceDepth = 0;
42
- for (let j = lineIdx - 1; j >= Math.max(0, lineIdx - 30); j--) {
42
+ for (let j = lineIdx - 1; j >= Math.max(0, lineIdx - 100); j--) {
43
43
  const prevLine = stripStrings(lines[j]);
44
44
  for (const ch of prevLine) {
45
45
  if (ch === '}')
@@ -56,7 +56,7 @@ export function isInsideTryBlock(lines, lineIdx) {
56
56
  }
57
57
  export function isInsidePythonTry(lines, lineIdx) {
58
58
  const lineIndent = lines[lineIdx].length - lines[lineIdx].trimStart().length;
59
- for (let j = lineIdx - 1; j >= Math.max(0, lineIdx - 30); j--) {
59
+ for (let j = lineIdx - 1; j >= Math.max(0, lineIdx - 100); j--) {
60
60
  const trimmed = lines[j].trim();
61
61
  if (trimmed === '')
62
62
  continue;
@@ -71,7 +71,7 @@ export function isInsidePythonTry(lines, lineIdx) {
71
71
  return false;
72
72
  }
73
73
  export function isInsideRubyRescue(lines, lineIdx) {
74
- for (let j = lineIdx - 1; j >= Math.max(0, lineIdx - 30); j--) {
74
+ for (let j = lineIdx - 1; j >= Math.max(0, lineIdx - 100); j--) {
75
75
  const trimmed = lines[j].trim();
76
76
  if (trimmed === 'begin')
77
77
  return true;
@@ -83,14 +83,14 @@ export function isInsideRubyRescue(lines, lineIdx) {
83
83
  return false;
84
84
  }
85
85
  export function hasCatchAhead(lines, idx) {
86
- for (let j = idx; j < Math.min(idx + 10, lines.length); j++) {
86
+ for (let j = idx; j < Math.min(idx + 50, lines.length); j++) {
87
87
  if (/\.catch\s*\(/.test(lines[j]))
88
88
  return true;
89
89
  }
90
90
  return false;
91
91
  }
92
92
  export function hasStatusCheckAhead(lines, idx) {
93
- for (let j = idx; j < Math.min(idx + 10, lines.length); j++) {
93
+ for (let j = idx; j < Math.min(idx + 50, lines.length); j++) {
94
94
  if (/\.\s*ok\b/.test(lines[j]) || /\.status(?:Text)?\b/.test(lines[j]))
95
95
  return true;
96
96
  }
@@ -36,9 +36,10 @@ export class PromiseSafetyGate extends Gate {
36
36
  return [];
37
37
  const violations = [];
38
38
  const allPatterns = Object.values(LANG_GLOBS).flat();
39
+ const scanPatterns = context.patterns || allPatterns;
39
40
  const files = await FileScanner.findFiles({
40
41
  cwd: context.cwd,
41
- patterns: allPatterns,
42
+ patterns: scanPatterns,
42
43
  ignore: [...(context.ignore || []), '**/node_modules/**', '**/dist/**', '**/build/**',
43
44
  '**/*.test.*', '**/*.spec.*', '**/vendor/**', '**/__pycache__/**',
44
45
  '**/bin/Debug/**', '**/bin/Release/**', '**/obj/**', '**/venv/**', '**/.venv/**'],
@@ -87,7 +88,7 @@ export class PromiseSafetyGate extends Gate {
87
88
  if (!/\.then\s*\(/.test(line))
88
89
  continue;
89
90
  let hasCatch = false;
90
- for (let j = i; j < Math.min(i + 10, lines.length); j++) {
91
+ for (let j = i; j < Math.min(i + 50, lines.length); j++) {
91
92
  const lookahead = this.sanitizeLine(lines[j]);
92
93
  if (/\.catch\s*\(/.test(lookahead)) {
93
94
  hasCatch = true;
@@ -209,7 +210,7 @@ export class PromiseSafetyGate extends Gate {
209
210
  if (!httpPatterns.test(lines[i]) || isInsidePythonTry(lines, i))
210
211
  continue;
211
212
  let hasCheck = false;
212
- for (let j = i; j < Math.min(i + 10, lines.length); j++) {
213
+ for (let j = i; j < Math.min(i + 50, lines.length); j++) {
213
214
  if (/raise_for_status|status_code/.test(lines[j])) {
214
215
  hasCheck = true;
215
216
  break;
@@ -317,7 +318,7 @@ export class PromiseSafetyGate extends Gate {
317
318
  for (let i = 0; i < lines.length; i++) {
318
319
  if (/\.(?:GetAsync|PostAsync|SendAsync)\s*\(/.test(lines[i]) && !isInsideTryBlock(lines, i)) {
319
320
  let hasCheck = false;
320
- for (let j = i; j < Math.min(i + 10, lines.length); j++) {
321
+ for (let j = i; j < Math.min(i + 50, lines.length); j++) {
321
322
  if (/EnsureSuccess|IsSuccessStatusCode|StatusCode/.test(lines[j])) {
322
323
  hasCheck = true;
323
324
  break;
@@ -199,13 +199,13 @@ export class GateRunner {
199
199
  }
200
200
  const isLocalDeepExecution = !deepOptions.apiKey || (deepOptions.provider || '').toLowerCase() === 'local';
201
201
  const deepTier = isLocalDeepExecution
202
- ? (deepOptions.pro ? 'pro' : 'deep')
202
+ ? (deepOptions.pro ? 'deep' : 'lite')
203
203
  : 'cloud';
204
204
  deepStats = {
205
205
  enabled: true,
206
206
  tier: deepTier,
207
207
  model: isLocalDeepExecution
208
- ? (deepOptions.pro ? 'Qwen2.5-Coder-1.5B' : 'Qwen2.5-Coder-0.5B')
208
+ ? (deepOptions.pro ? 'Qwen2.5-Coder-1.5B' : 'Qwen3.5-0.8B')
209
209
  : (deepOptions.modelName || deepOptions.provider || 'cloud'),
210
210
  total_ms: Date.now() - deepSetupStart,
211
211
  findings_count: deepFailures.length,
@@ -25,7 +25,7 @@ describe('GateRunner deep stats execution mode', () => {
25
25
  },
26
26
  });
27
27
  }
28
- it('reports local deep tier when provider=local even with apiKey', async () => {
28
+ it('reports local lite tier when provider=local even with apiKey', async () => {
29
29
  vi.spyOn(DeepAnalysisGate.prototype, 'run').mockResolvedValue([]);
30
30
  const runner = createRunner();
31
31
  const report = await runner.run(testDir, undefined, {
@@ -34,10 +34,10 @@ describe('GateRunner deep stats execution mode', () => {
34
34
  provider: 'local',
35
35
  pro: false,
36
36
  });
37
- expect(report.stats.deep?.tier).toBe('deep');
38
- expect(report.stats.deep?.model).toBe('Qwen2.5-Coder-0.5B');
37
+ expect(report.stats.deep?.tier).toBe('lite');
38
+ expect(report.stats.deep?.model).toBe('Qwen3.5-0.8B');
39
39
  });
40
- it('reports local pro tier when provider=local and pro=true', async () => {
40
+ it('reports local deep tier when provider=local and pro=true', async () => {
41
41
  vi.spyOn(DeepAnalysisGate.prototype, 'run').mockResolvedValue([]);
42
42
  const runner = createRunner();
43
43
  const report = await runner.run(testDir, undefined, {
@@ -46,7 +46,7 @@ describe('GateRunner deep stats execution mode', () => {
46
46
  provider: 'local',
47
47
  pro: true,
48
48
  });
49
- expect(report.stats.deep?.tier).toBe('pro');
49
+ expect(report.stats.deep?.tier).toBe('deep');
50
50
  expect(report.stats.deep?.model).toBe('Qwen2.5-Coder-1.5B');
51
51
  });
52
52
  it('reports cloud tier/model for cloud providers', async () => {
@@ -251,9 +251,10 @@ export class SecurityPatternsGate extends Gate {
251
251
  }
252
252
  const failures = [];
253
253
  const vulnerabilities = [];
254
+ const scanPatterns = context.patterns || ['**/*.{ts,js,tsx,jsx,py,java,go}'];
254
255
  const files = await FileScanner.findFiles({
255
256
  cwd: context.cwd,
256
- patterns: ['**/*.{ts,js,tsx,jsx,py,java,go}'],
257
+ patterns: scanPatterns,
257
258
  ignore: [...(context.ignore || []), '**/node_modules/**', '**/dist/**', '**/build/**', '**/.next/**', '**/coverage/**'],
258
259
  });
259
260
  const scanFiles = files.filter(file => !this.shouldSkipSecurityFile(file));
@@ -73,9 +73,10 @@ export class SideEffectAnalysisGate extends Gate {
73
73
  if (!this.cfg.enabled)
74
74
  return [];
75
75
  const violations = [];
76
+ const scanPatterns = context.patterns || FILE_GLOBS;
76
77
  const files = await FileScanner.findFiles({
77
78
  cwd: context.cwd,
78
- patterns: FILE_GLOBS,
79
+ patterns: scanPatterns,
79
80
  ignore: [
80
81
  ...(context.ignore || []),
81
82
  '**/node_modules/**', '**/dist/**', '**/build/**',
@@ -50,16 +50,18 @@ export class TestQualityGate extends Gate {
50
50
  return [];
51
51
  const failures = [];
52
52
  const issues = [];
53
+ const defaultPatterns = [
54
+ '**/*.test.{ts,js,tsx,jsx}', '**/*.spec.{ts,js,tsx,jsx}',
55
+ '**/__tests__/**/*.{ts,js,tsx,jsx}',
56
+ '**/test_*.py', '**/*_test.py', '**/tests/**/*.py',
57
+ '**/*_test.go',
58
+ '**/*Test.java', '**/*Tests.java', '**/src/test/**/*.java',
59
+ '**/*Test.kt', '**/*Tests.kt', '**/src/test/**/*.kt',
60
+ ];
61
+ const scanPatterns = context.patterns || defaultPatterns;
53
62
  const files = await FileScanner.findFiles({
54
63
  cwd: context.cwd,
55
- patterns: [
56
- '**/*.test.{ts,js,tsx,jsx}', '**/*.spec.{ts,js,tsx,jsx}',
57
- '**/__tests__/**/*.{ts,js,tsx,jsx}',
58
- '**/test_*.py', '**/*_test.py', '**/tests/**/*.py',
59
- '**/*_test.go',
60
- '**/*Test.java', '**/*Tests.java', '**/src/test/**/*.java',
61
- '**/*Test.kt', '**/*Tests.kt', '**/src/test/**/*.kt',
62
- ],
64
+ patterns: scanPatterns,
63
65
  ignore: [...(context.ignore || []), '**/node_modules/**', '**/dist/**', '**/build/**',
64
66
  '**/.venv/**', '**/venv/**', '**/vendor/**',
65
67
  '**/target/**', '**/.gradle/**', '**/out/**'],
@@ -2,13 +2,13 @@
2
2
  const DEFAULT_MODELS = {
3
3
  claude: 'claude-opus-4-6',
4
4
  anthropic: 'claude-sonnet-4-6',
5
- openai: 'gpt-4o-mini',
6
- gemini: 'gemini-3-flash',
7
- groq: 'llama-3.1-70b-versatile',
5
+ openai: 'gpt-5-mini',
6
+ gemini: 'gemini-2.5-flash',
7
+ groq: 'llama-3.3-70b-versatile',
8
8
  mistral: 'mistral-large-latest',
9
- together: 'meta-llama/Llama-3.1-70B-Instruct-Turbo',
10
- fireworks: 'accounts/fireworks/models/llama-v3p1-70b-instruct',
11
- deepseek: 'deepseek-coder',
9
+ together: 'meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8',
10
+ fireworks: 'accounts/fireworks/models/llama-v3p3-70b-instruct',
11
+ deepseek: 'deepseek-v4',
12
12
  perplexity: 'llama-3.1-sonar-large-128k-online',
13
13
  ollama: 'qwen2.5-coder:7b',
14
14
  lmstudio: 'qwen2.5-coder-7b-instruct',
@@ -18,6 +18,8 @@ export function createProvider(options) {
18
18
  });
19
19
  }
20
20
  // Default: local sidecar
21
- const tier = options.pro ? 'pro' : 'deep';
21
+ // deep = Qwen2.5-Coder-1.5B (full power, company-hosted)
22
+ // lite = Qwen3.5-0.8B (lightweight, default CLI sidecar)
23
+ const tier = options.pro ? 'deep' : 'lite';
22
24
  return new SidecarProvider(tier);
23
25
  }
@@ -13,9 +13,17 @@ export declare function getModelPath(tier: ModelTier): string;
13
13
  * Get model info for a tier.
14
14
  */
15
15
  export declare function getModelInfo(tier: ModelTier): ModelInfo;
16
+ /**
17
+ * Check HuggingFace for a newer model version (like antivirus signature updates).
18
+ * Reads latest_version.json from the RLAIF dataset repo. Non-blocking — if the
19
+ * check fails (offline, HF down), we silently use the cached/bundled version.
20
+ *
21
+ * Results are cached locally for 24 hours to avoid hammering HF on every run.
22
+ */
23
+ export declare function checkForUpdates(onProgress?: (message: string, percent?: number) => void): Promise<string>;
16
24
  /**
17
25
  * Download a model from HuggingFace CDN.
18
- * Tries fine-tuned model first, falls back to stock Qwen if unavailable.
26
+ * Checks for updates first, then tries fine-tuned model, falls back to stock Qwen.
19
27
  */
20
28
  export declare function downloadModel(tier: ModelTier, onProgress?: (message: string, percent?: number) => void): Promise<string>;
21
29
  /**
@@ -6,8 +6,11 @@ import path from 'path';
6
6
  import fs from 'fs-extra';
7
7
  import { createHash } from 'crypto';
8
8
  import { RIGOUR_DIR } from '../storage/db.js';
9
- import { MODELS, FALLBACK_MODELS } from './types.js';
9
+ import { MODELS, FALLBACK_MODELS, VERSION_CHECK_URL, BUNDLED_MODEL_VERSION, updateModelVersion } from './types.js';
10
10
  const MODELS_DIR = path.join(RIGOUR_DIR, 'models');
11
+ const VERSION_CACHE_PATH = path.join(MODELS_DIR, '.latest_version.json');
12
+ const VERSION_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // Check once per day
13
+ const VERSION_CHECK_TIMEOUT_MS = 5000; // 5s timeout — don't block startup
11
14
  const SHA256_RE = /^[a-f0-9]{64}$/i;
12
15
  function getModelMetadataPath(filename) {
13
16
  return path.join(MODELS_DIR, filename + '.meta.json');
@@ -173,12 +176,66 @@ async function downloadFromUrl(tier, model, onProgress) {
173
176
  throw error;
174
177
  }
175
178
  }
179
+ /**
180
+ * Check HuggingFace for a newer model version (like antivirus signature updates).
181
+ * Reads latest_version.json from the RLAIF dataset repo. Non-blocking — if the
182
+ * check fails (offline, HF down), we silently use the cached/bundled version.
183
+ *
184
+ * Results are cached locally for 24 hours to avoid hammering HF on every run.
185
+ */
186
+ export async function checkForUpdates(onProgress) {
187
+ fs.ensureDirSync(MODELS_DIR);
188
+ // Check local version cache first — avoid network on every run
189
+ try {
190
+ if (await fs.pathExists(VERSION_CACHE_PATH)) {
191
+ const cached = await fs.readJson(VERSION_CACHE_PATH);
192
+ const age = Date.now() - new Date(cached.checkedAt).getTime();
193
+ if (age < VERSION_CHECK_INTERVAL_MS && cached.version) {
194
+ const v = String(cached.version);
195
+ updateModelVersion(v);
196
+ return v;
197
+ }
198
+ }
199
+ }
200
+ catch {
201
+ // Corrupted cache — proceed to network check
202
+ }
203
+ // Fetch latest version from HuggingFace (with timeout)
204
+ try {
205
+ const controller = new AbortController();
206
+ const timeout = setTimeout(() => controller.abort(), VERSION_CHECK_TIMEOUT_MS);
207
+ const response = await fetch(VERSION_CHECK_URL, { signal: controller.signal });
208
+ clearTimeout(timeout);
209
+ if (response.ok) {
210
+ const data = await response.json();
211
+ const latestVersion = String(data.version || BUNDLED_MODEL_VERSION);
212
+ // Cache the result locally
213
+ await fs.writeJson(VERSION_CACHE_PATH, {
214
+ version: latestVersion,
215
+ checkedAt: new Date().toISOString(),
216
+ source: 'huggingface',
217
+ }, { spaces: 2 }).catch(() => { });
218
+ // Update in-memory model definitions
219
+ updateModelVersion(latestVersion);
220
+ if (latestVersion !== BUNDLED_MODEL_VERSION) {
221
+ onProgress?.(`Model update available: v${latestVersion}`, 0);
222
+ }
223
+ return latestVersion;
224
+ }
225
+ }
226
+ catch {
227
+ // Offline / HF down / timeout — use bundled version silently
228
+ }
229
+ return BUNDLED_MODEL_VERSION;
230
+ }
176
231
  /**
177
232
  * Download a model from HuggingFace CDN.
178
- * Tries fine-tuned model first, falls back to stock Qwen if unavailable.
233
+ * Checks for updates first, then tries fine-tuned model, falls back to stock Qwen.
179
234
  */
180
235
  export async function downloadModel(tier, onProgress) {
181
236
  fs.ensureDirSync(MODELS_DIR);
237
+ // Check for newer model version (non-blocking, cached 24h)
238
+ await checkForUpdates(onProgress);
182
239
  if (await isModelCached(tier)) {
183
240
  onProgress?.(`Model ${MODELS[tier].name} already cached`, 100);
184
241
  return getModelPath(tier);
@@ -27,7 +27,7 @@ export class SidecarProvider {
27
27
  modelPath = null;
28
28
  tier;
29
29
  threads;
30
- constructor(tier = 'deep', threads = 4) {
30
+ constructor(tier = 'lite', threads = 4) {
31
31
  this.tier = tier;
32
32
  this.threads = threads;
33
33
  }
@@ -60,8 +60,12 @@ export interface DeepAnalysisResult {
60
60
  }
61
61
  /**
62
62
  * Available model tiers.
63
+ *
64
+ * - deep: Qwen2.5-Coder-1.5B fine-tuned — full power, company-hosted
65
+ * - lite: Qwen2.5-Coder-0.5B fine-tuned — lightweight, ships as default CLI sidecar
66
+ * - legacy: Qwen2.5-Coder-0.5B fine-tuned — previous default, reproducibility
63
67
  */
64
- export type ModelTier = 'deep' | 'pro';
68
+ export type ModelTier = 'deep' | 'lite' | 'legacy';
65
69
  /**
66
70
  * Model info for download/caching.
67
71
  */
@@ -74,13 +78,24 @@ export interface ModelInfo {
74
78
  sizeHuman: string;
75
79
  }
76
80
  /**
77
- * Model version — bump when new fine-tuned GGUF is published.
78
- * The RLAIF pipeline uploads new models to HuggingFace, and
79
- * model-manager checks this version to auto-update.
81
+ * Minimum bundled model version — used as fallback when the auto-update
82
+ * check fails (offline, HF down, first run). The RLAIF training pipeline
83
+ * publishes new versions to HuggingFace and updates latest_version.json.
84
+ * At startup, model-manager checks HF for the latest version and downloads
85
+ * it automatically (like antivirus signature updates).
80
86
  */
81
- export declare const MODEL_VERSION = "1";
82
- /** All supported model definitions */
87
+ export declare const BUNDLED_MODEL_VERSION = "1";
88
+ /** HuggingFace dataset repo where latest_version.json lives */
89
+ export declare const VERSION_CHECK_URL = "https://huggingface.co/datasets/rigour-labs/rigour-rlaif-data/resolve/main/latest_version.json";
90
+ /** Build model info for a given tier and version */
91
+ export declare function buildModelInfo(tier: ModelTier, version: string): ModelInfo;
92
+ /** Current model definitions — initialized with bundled version, updated at runtime */
83
93
  export declare const MODELS: Record<ModelTier, ModelInfo>;
94
+ /**
95
+ * Update MODELS in-place to point to a newer version.
96
+ * Called by model-manager after checking latest_version.json.
97
+ */
98
+ export declare function updateModelVersion(version: string): void;
84
99
  /**
85
100
  * Fallback stock models — used when fine-tuned model is not yet
86
101
  * available on HuggingFace (initial setup / first-time users).
@@ -1,28 +1,46 @@
1
1
  /**
2
- * Model version — bump when new fine-tuned GGUF is published.
3
- * The RLAIF pipeline uploads new models to HuggingFace, and
4
- * model-manager checks this version to auto-update.
2
+ * Minimum bundled model version — used as fallback when the auto-update
3
+ * check fails (offline, HF down, first run). The RLAIF training pipeline
4
+ * publishes new versions to HuggingFace and updates latest_version.json.
5
+ * At startup, model-manager checks HF for the latest version and downloads
6
+ * it automatically (like antivirus signature updates).
5
7
  */
6
- export const MODEL_VERSION = '1';
7
- /** All supported model definitions */
8
+ export const BUNDLED_MODEL_VERSION = '1';
9
+ /** HuggingFace dataset repo where latest_version.json lives */
10
+ export const VERSION_CHECK_URL = 'https://huggingface.co/datasets/rigour-labs/rigour-rlaif-data/resolve/main/latest_version.json';
11
+ /** Build model info for a given tier and version */
12
+ export function buildModelInfo(tier, version) {
13
+ const meta = {
14
+ deep: { base: 'Qwen2.5-Coder-1.5B', size: 900_000_000, sizeH: '900MB' },
15
+ lite: { base: 'Qwen2.5-Coder-0.5B', size: 500_000_000, sizeH: '500MB' },
16
+ legacy: { base: 'Qwen2.5-Coder-0.5B', size: 350_000_000, sizeH: '350MB' },
17
+ };
18
+ const m = meta[tier];
19
+ return {
20
+ tier,
21
+ name: `Rigour-${tier[0].toUpperCase() + tier.slice(1)}-v${version} (${m.base} fine-tuned)`,
22
+ filename: `rigour-${tier}-v${version}-q4_k_m.gguf`,
23
+ url: `https://huggingface.co/rigour-labs/rigour-${tier}-v${version}-gguf/resolve/main/rigour-${tier}-v${version}-q4_k_m.gguf`,
24
+ sizeBytes: m.size,
25
+ sizeHuman: m.sizeH,
26
+ };
27
+ }
28
+ /** Current model definitions — initialized with bundled version, updated at runtime */
8
29
  export const MODELS = {
9
- deep: {
10
- tier: 'deep',
11
- name: 'Rigour-Deep-v1 (Qwen2.5-Coder-0.5B fine-tuned)',
12
- filename: `rigour-deep-v${MODEL_VERSION}-q4_k_m.gguf`,
13
- url: `https://huggingface.co/rigour-labs/rigour-deep-v1-gguf/resolve/main/rigour-deep-v${MODEL_VERSION}-q4_k_m.gguf`,
14
- sizeBytes: 350_000_000,
15
- sizeHuman: '350MB',
16
- },
17
- pro: {
18
- tier: 'pro',
19
- name: 'Rigour-Pro-v1 (Qwen2.5-Coder-1.5B fine-tuned)',
20
- filename: `rigour-pro-v${MODEL_VERSION}-q4_k_m.gguf`,
21
- url: `https://huggingface.co/rigour-labs/rigour-pro-v1-gguf/resolve/main/rigour-pro-v${MODEL_VERSION}-q4_k_m.gguf`,
22
- sizeBytes: 900_000_000,
23
- sizeHuman: '900MB',
24
- },
30
+ deep: buildModelInfo('deep', BUNDLED_MODEL_VERSION),
31
+ lite: buildModelInfo('lite', BUNDLED_MODEL_VERSION),
32
+ legacy: buildModelInfo('legacy', BUNDLED_MODEL_VERSION),
25
33
  };
34
+ /**
35
+ * Update MODELS in-place to point to a newer version.
36
+ * Called by model-manager after checking latest_version.json.
37
+ */
38
+ export function updateModelVersion(version) {
39
+ for (const tier of ['deep', 'lite', 'legacy']) {
40
+ const updated = buildModelInfo(tier, version);
41
+ MODELS[tier] = updated;
42
+ }
43
+ }
26
44
  /**
27
45
  * Fallback stock models — used when fine-tuned model is not yet
28
46
  * available on HuggingFace (initial setup / first-time users).
@@ -30,18 +48,26 @@ export const MODELS = {
30
48
  export const FALLBACK_MODELS = {
31
49
  deep: {
32
50
  tier: 'deep',
33
- name: 'Qwen2.5-Coder-0.5B-Instruct (stock)',
34
- filename: 'qwen2.5-coder-0.5b-instruct-q4_k_m.gguf',
35
- url: 'https://huggingface.co/Qwen/Qwen2.5-Coder-0.5B-Instruct-GGUF/resolve/main/qwen2.5-coder-0.5b-instruct-q4_k_m.gguf',
36
- sizeBytes: 350_000_000,
37
- sizeHuman: '350MB',
38
- },
39
- pro: {
40
- tier: 'pro',
41
51
  name: 'Qwen2.5-Coder-1.5B-Instruct (stock)',
42
52
  filename: 'qwen2.5-coder-1.5b-instruct-q4_k_m.gguf',
43
53
  url: 'https://huggingface.co/Qwen/Qwen2.5-Coder-1.5B-Instruct-GGUF/resolve/main/qwen2.5-coder-1.5b-instruct-q4_k_m.gguf',
44
54
  sizeBytes: 900_000_000,
45
55
  sizeHuman: '900MB',
46
56
  },
57
+ lite: {
58
+ tier: 'lite',
59
+ name: 'Qwen2.5-Coder-0.5B-Instruct (stock)',
60
+ filename: 'qwen2.5-coder-0.5b-instruct-q4_k_m.gguf',
61
+ url: 'https://huggingface.co/Qwen/Qwen2.5-Coder-0.5B-Instruct-GGUF/resolve/main/qwen2.5-coder-0.5b-instruct-q4_k_m.gguf',
62
+ sizeBytes: 500_000_000,
63
+ sizeHuman: '500MB',
64
+ },
65
+ legacy: {
66
+ tier: 'legacy',
67
+ name: 'Qwen2.5-Coder-0.5B-Instruct (stock)',
68
+ filename: 'qwen2.5-coder-0.5b-instruct-q4_k_m.gguf',
69
+ url: 'https://huggingface.co/Qwen/Qwen2.5-Coder-0.5B-Instruct-GGUF/resolve/main/qwen2.5-coder-0.5b-instruct-q4_k_m.gguf',
70
+ sizeBytes: 350_000_000,
71
+ sizeHuman: '350MB',
72
+ },
47
73
  };
@@ -2748,7 +2748,7 @@ export declare const ReportSchema: z.ZodObject<{
2748
2748
  }>>;
2749
2749
  deep: z.ZodOptional<z.ZodObject<{
2750
2750
  enabled: z.ZodBoolean;
2751
- tier: z.ZodOptional<z.ZodEnum<["deep", "pro", "cloud"]>>;
2751
+ tier: z.ZodOptional<z.ZodEnum<["deep", "lite", "legacy", "cloud"]>>;
2752
2752
  model: z.ZodOptional<z.ZodString>;
2753
2753
  total_ms: z.ZodOptional<z.ZodNumber>;
2754
2754
  files_analyzed: z.ZodOptional<z.ZodNumber>;
@@ -2756,7 +2756,7 @@ export declare const ReportSchema: z.ZodObject<{
2756
2756
  findings_verified: z.ZodOptional<z.ZodNumber>;
2757
2757
  }, "strip", z.ZodTypeAny, {
2758
2758
  enabled: boolean;
2759
- tier?: "deep" | "pro" | "cloud" | undefined;
2759
+ tier?: "deep" | "lite" | "legacy" | "cloud" | undefined;
2760
2760
  model?: string | undefined;
2761
2761
  total_ms?: number | undefined;
2762
2762
  files_analyzed?: number | undefined;
@@ -2764,7 +2764,7 @@ export declare const ReportSchema: z.ZodObject<{
2764
2764
  findings_verified?: number | undefined;
2765
2765
  }, {
2766
2766
  enabled: boolean;
2767
- tier?: "deep" | "pro" | "cloud" | undefined;
2767
+ tier?: "deep" | "lite" | "legacy" | "cloud" | undefined;
2768
2768
  model?: string | undefined;
2769
2769
  total_ms?: number | undefined;
2770
2770
  files_analyzed?: number | undefined;
@@ -2775,7 +2775,7 @@ export declare const ReportSchema: z.ZodObject<{
2775
2775
  duration_ms: number;
2776
2776
  deep?: {
2777
2777
  enabled: boolean;
2778
- tier?: "deep" | "pro" | "cloud" | undefined;
2778
+ tier?: "deep" | "lite" | "legacy" | "cloud" | undefined;
2779
2779
  model?: string | undefined;
2780
2780
  total_ms?: number | undefined;
2781
2781
  files_analyzed?: number | undefined;
@@ -2798,7 +2798,7 @@ export declare const ReportSchema: z.ZodObject<{
2798
2798
  duration_ms: number;
2799
2799
  deep?: {
2800
2800
  enabled: boolean;
2801
- tier?: "deep" | "pro" | "cloud" | undefined;
2801
+ tier?: "deep" | "lite" | "legacy" | "cloud" | undefined;
2802
2802
  model?: string | undefined;
2803
2803
  total_ms?: number | undefined;
2804
2804
  files_analyzed?: number | undefined;
@@ -2823,7 +2823,7 @@ export declare const ReportSchema: z.ZodObject<{
2823
2823
  duration_ms: number;
2824
2824
  deep?: {
2825
2825
  enabled: boolean;
2826
- tier?: "deep" | "pro" | "cloud" | undefined;
2826
+ tier?: "deep" | "lite" | "legacy" | "cloud" | undefined;
2827
2827
  model?: string | undefined;
2828
2828
  total_ms?: number | undefined;
2829
2829
  files_analyzed?: number | undefined;
@@ -2865,7 +2865,7 @@ export declare const ReportSchema: z.ZodObject<{
2865
2865
  duration_ms: number;
2866
2866
  deep?: {
2867
2867
  enabled: boolean;
2868
- tier?: "deep" | "pro" | "cloud" | undefined;
2868
+ tier?: "deep" | "lite" | "legacy" | "cloud" | undefined;
2869
2869
  model?: string | undefined;
2870
2870
  total_ms?: number | undefined;
2871
2871
  files_analyzed?: number | undefined;
@@ -383,7 +383,7 @@ export const ReportSchema = z.object({
383
383
  }).optional(),
384
384
  deep: z.object({
385
385
  enabled: z.boolean(),
386
- tier: z.enum(['deep', 'pro', 'cloud']).optional(),
386
+ tier: z.enum(['deep', 'lite', 'legacy', 'cloud']).optional(),
387
387
  model: z.string().optional(),
388
388
  total_ms: z.number().optional(),
389
389
  files_analyzed: z.number().optional(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rigour-labs/core",
3
- "version": "4.3.2",
3
+ "version": "4.3.4",
4
4
  "description": "Deterministic quality gate engine for AI-generated code. AST analysis, drift detection, and Fix Packet generation across TypeScript, JavaScript, Python, Go, Ruby, and C#.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://rigour.run",
@@ -59,11 +59,11 @@
59
59
  "@xenova/transformers": "^2.17.2",
60
60
  "better-sqlite3": "^11.0.0",
61
61
  "openai": "^4.104.0",
62
- "@rigour-labs/brain-darwin-arm64": "4.3.2",
63
- "@rigour-labs/brain-linux-arm64": "4.3.2",
64
- "@rigour-labs/brain-darwin-x64": "4.3.2",
65
- "@rigour-labs/brain-win-x64": "4.3.2",
66
- "@rigour-labs/brain-linux-x64": "4.3.2"
62
+ "@rigour-labs/brain-darwin-arm64": "4.3.4",
63
+ "@rigour-labs/brain-darwin-x64": "4.3.4",
64
+ "@rigour-labs/brain-linux-x64": "4.3.4",
65
+ "@rigour-labs/brain-linux-arm64": "4.3.4",
66
+ "@rigour-labs/brain-win-x64": "4.3.4"
67
67
  },
68
68
  "devDependencies": {
69
69
  "@types/better-sqlite3": "^7.6.12",