agentic-kdd 3.5.3 → 3.5.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentic-kdd",
3
- "version": "3.5.3",
3
+ "version": "3.5.4",
4
4
  "description": "Autonomous development pipeline — aa: · ag: · audit: · AST graph · Harness · Specs · Impact analysis · Decision trail · Metrics · MCP server. Works with Cursor and Claude Code.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -0,0 +1,514 @@
1
+ /**
2
+ * Regression Guard — Agentic KDD v3.6
3
+ *
4
+ * Resuelve: "arreglé una cosa y rompí otra que ya funcionaba"
5
+ *
6
+ * Dos momentos de acción:
7
+ * ANTES del build: checkBeforeBuild() — ¿este cambio rompería algo sano?
8
+ * DESPUÉS del ciclo: registerBehavior() — guardar snapshot de lo que quedó bien
9
+ *
10
+ * Auto-registration: no requiere intervención del dev.
11
+ * El sistema infiere módulo, archivos y tests del ciclo exitoso.
12
+ */
13
+
14
+ 'use strict';
15
+
16
+ const path = require('path');
17
+ const fs = require('fs');
18
+ const crypto = require('crypto');
19
+ const { execSync } = require('child_process');
20
+
21
+ // ─── SCHEMA ───────────────────────────────────────────────────────────────────
22
+
23
+ function ensureSchema(db) {
24
+ db.exec(`
25
+ CREATE TABLE IF NOT EXISTS protected_behaviors (
26
+ id TEXT PRIMARY KEY,
27
+ module TEXT NOT NULL,
28
+ description TEXT NOT NULL,
29
+ critical_flows TEXT DEFAULT '[]',
30
+ test_patterns TEXT DEFAULT '[]',
31
+ related_files TEXT DEFAULT '[]',
32
+ pass_count INTEGER DEFAULT 1,
33
+ confidence TEXT DEFAULT 'MEDIA',
34
+ status TEXT DEFAULT 'active',
35
+ last_verified_at TEXT DEFAULT (datetime('now')),
36
+ created_at TEXT DEFAULT (datetime('now'))
37
+ );
38
+
39
+ CREATE TABLE IF NOT EXISTS invariant_violations (
40
+ id TEXT PRIMARY KEY,
41
+ behavior_id TEXT NOT NULL,
42
+ cycle INTEGER DEFAULT 0,
43
+ changed_files TEXT DEFAULT '[]',
44
+ failed_tests TEXT DEFAULT '[]',
45
+ description TEXT,
46
+ fixed_at TEXT,
47
+ created_at TEXT DEFAULT (datetime('now')),
48
+ FOREIGN KEY (behavior_id) REFERENCES protected_behaviors(id)
49
+ );
50
+
51
+ CREATE INDEX IF NOT EXISTS idx_pb_module ON protected_behaviors(module);
52
+ CREATE INDEX IF NOT EXISTS idx_pb_status ON protected_behaviors(status);
53
+ CREATE INDEX IF NOT EXISTS idx_iv_behavior ON invariant_violations(behavior_id);
54
+ `);
55
+ }
56
+
57
+ // ─── HELPERS ──────────────────────────────────────────────────────────────────
58
+
59
+ const safe = (fn, fb = null) => { try { return fn(); } catch { return fb; } };
60
+ const parseJ = (s, fb = []) => { try { return JSON.parse(s); } catch { return fb; } };
61
+
62
+ function inferModule(filePaths) {
63
+ const segments = filePaths
64
+ .map(f => f.replace(/\\/g, '/'))
65
+ .flatMap(f => f.split('/'))
66
+ .map(s => s.replace(/\.(ts|js|cjs|mjs)$/, ''))
67
+ .filter(s => s && !['src','routes','lib','middleware','tests','unit','integration','index'].includes(s));
68
+
69
+ const counts = {};
70
+ segments.forEach(s => { counts[s] = (counts[s] || 0) + 1; });
71
+ const sorted = Object.entries(counts).sort((a, b) => b[1] - a[1]);
72
+ return sorted[0]?.[0] || 'global';
73
+ }
74
+
75
+ function extractFlows(filePaths, projectRoot) {
76
+ const flows = [];
77
+ const methods = ['get', 'post', 'put', 'patch', 'delete'];
78
+
79
+ filePaths.forEach(fp => {
80
+ const full = path.isAbsolute(fp) ? fp : path.join(projectRoot, fp);
81
+ if (!fs.existsSync(full)) return;
82
+ const content = safe(() => fs.readFileSync(full, 'utf8'), '');
83
+
84
+ methods.forEach(method => {
85
+ const regex = new RegExp(`app\\.${method}\\(['"\`]([^'"\`]+)`, 'gi');
86
+ const matches = content.matchAll(regex);
87
+ for (const m of matches) {
88
+ flows.push(`${method.toUpperCase()} ${m[1]}`);
89
+ }
90
+ });
91
+ });
92
+
93
+ return [...new Set(flows)].slice(0, 20);
94
+ }
95
+
96
+ function inferTestPatterns(filePaths) {
97
+ return filePaths
98
+ .map(f => path.basename(f.replace(/\\/g, '/')))
99
+ .filter(f => f.includes('.test.') || f.includes('.spec.'))
100
+ .filter((v, i, a) => a.indexOf(v) === i);
101
+ }
102
+
103
+ function findRelatedBehaviors(db, filePaths) {
104
+ const behaviors = safe(() =>
105
+ db.prepare(`
106
+ SELECT * FROM protected_behaviors
107
+ WHERE status = 'active'
108
+ AND confidence IN ('HIGH', 'MEDIA')
109
+ `).all()
110
+ ) || [];
111
+
112
+ const fpNorm = filePaths.map(f => f.replace(/\\/g, '/').toLowerCase());
113
+
114
+ return behaviors.filter(b => {
115
+ const bFiles = parseJ(b.related_files, []).map(f => f.replace(/\\/g, '/').toLowerCase());
116
+ return bFiles.some(bf => fpNorm.some(fp => fp.includes(bf) || bf.includes(fp)));
117
+ });
118
+ }
119
+
120
+ function runTestFile(testPattern, projectRoot) {
121
+ try {
122
+ const isWin = process.platform === 'win32';
123
+ const shell = isWin ? 'cmd.exe' : 'sh';
124
+ const flag = isWin ? '/c' : '-c';
125
+ const cmd = `npm test -- --testPathPattern="${testPattern}" 2>&1`;
126
+
127
+ const result = require('child_process').spawnSync(
128
+ shell, [flag, cmd],
129
+ { cwd: projectRoot, timeout: 60000, encoding: 'utf8', stdio: 'pipe' }
130
+ );
131
+
132
+ const output = (result.stdout || '') + (result.stderr || '');
133
+ const clean = output.replace(/\x1b\[[0-9;]*[mGKHF]/g, '');
134
+
135
+ const passed = clean.match(/(\d+)\s+passed/i)?.[1];
136
+ const failed = clean.match(/(\d+)\s+failed/i)?.[1];
137
+
138
+ return {
139
+ passed: parseInt(passed || '0'),
140
+ failed: parseInt(failed || '0'),
141
+ allPassed: result.status === 0 || (!failed && !!passed),
142
+ output: clean.slice(-500),
143
+ };
144
+ } catch(e) {
145
+ return { passed: 0, failed: 1, allPassed: false, output: e.message };
146
+ }
147
+ }
148
+
149
+ // ─── CORE FUNCTIONS ───────────────────────────────────────────────────────────
150
+
151
+ /**
152
+ * STEP 4 — llamar ANTES del build.
153
+ * Si encuentra behaviors HIGH relacionados con los archivos → corre sus tests.
154
+ * Si alguno falla → STOP.
155
+ */
156
+ function checkBeforeBuild(db, filesToChange, projectRoot) {
157
+ ensureSchema(db);
158
+ projectRoot = projectRoot || process.cwd();
159
+
160
+ const related = findRelatedBehaviors(db, filesToChange);
161
+ if (related.length === 0) {
162
+ return { passed: true, reason: 'No protected behaviors related to this changeset' };
163
+ }
164
+
165
+ const highConfidence = related.filter(b => b.confidence === 'HIGH');
166
+ const mediaConfidence = related.filter(b => b.confidence === 'MEDIA');
167
+ const violations = [];
168
+ const warnings = [];
169
+
170
+ // HIGH confidence → run tests, block if any fail
171
+ highConfidence.forEach(behavior => {
172
+ const patterns = parseJ(behavior.test_patterns, []);
173
+ patterns.forEach(pattern => {
174
+ const result = runTestFile(pattern, projectRoot);
175
+ if (!result.allPassed) {
176
+ violations.push({
177
+ behavior_id: behavior.id,
178
+ behavior: behavior.description,
179
+ module: behavior.module,
180
+ test_pattern: pattern,
181
+ failed: result.failed,
182
+ confidence: 'HIGH',
183
+ });
184
+ }
185
+ });
186
+ });
187
+
188
+ // MEDIA confidence → warn but don't block
189
+ mediaConfidence.forEach(behavior => {
190
+ warnings.push({
191
+ behavior: behavior.description,
192
+ module: behavior.module,
193
+ confidence: 'MEDIA',
194
+ });
195
+ });
196
+
197
+ if (violations.length > 0) {
198
+ return {
199
+ passed: false,
200
+ violations,
201
+ warnings,
202
+ message: [
203
+ `🛑 REGRESSION GUARD STOP: ${violations.length} protected behavior(s) at risk:`,
204
+ ...violations.map(v =>
205
+ ` [HIGH] "${v.behavior}" (${v.module}) — test "${v.test_pattern}" currently failing`
206
+ ),
207
+ '',
208
+ 'Fix the failing tests before modifying these files.',
209
+ 'To override: add --override-regression to your aa: command.',
210
+ ].join('\n'),
211
+ };
212
+ }
213
+
214
+ const result = { passed: true };
215
+ if (warnings.length > 0) {
216
+ result.warnings = warnings;
217
+ result.message = `⚠️ REGRESSION GUARD WARN: ${warnings.length} MEDIA behavior(s) in changeset path — proceed carefully.`;
218
+ }
219
+ return result;
220
+ }
221
+
222
+ /**
223
+ * STEP 9 — llamar DESPUÉS de TDD Gate PASS + QA PASS.
224
+ * Auto-registra snapshot de comportamientos sanos.
225
+ * No requiere intervención del dev.
226
+ */
227
+ function registerBehavior(db, params) {
228
+ ensureSchema(db);
229
+
230
+ const {
231
+ module: moduleName,
232
+ files: changedFiles = [],
233
+ testFiles: testPassed = [],
234
+ testOutput,
235
+ projectRoot,
236
+ } = params;
237
+
238
+ const root = projectRoot || process.cwd();
239
+ const module_ = moduleName || inferModule(changedFiles);
240
+ const flows = extractFlows(changedFiles, root);
241
+ const tests = testPassed.length > 0 ? testPassed : inferTestPatterns(changedFiles);
242
+
243
+ if (module_ === 'global' && changedFiles.length === 0) return null;
244
+
245
+ const description = `${module_} module — ${flows.length > 0
246
+ ? flows.slice(0, 3).join(', ')
247
+ : `${changedFiles.length} files`} functioning correctly`;
248
+
249
+ // Check if behavior for this module already exists
250
+ const existing = safe(() =>
251
+ db.prepare(`
252
+ SELECT id, pass_count, confidence FROM protected_behaviors
253
+ WHERE module = ? AND status = 'active'
254
+ LIMIT 1
255
+ `).get(module_)
256
+ );
257
+
258
+ if (existing) {
259
+ const newCount = existing.pass_count + 1;
260
+ const newConfidence = newCount >= 5 ? 'HIGH' : 'MEDIA';
261
+
262
+ safe(() =>
263
+ db.prepare(`
264
+ UPDATE protected_behaviors SET
265
+ pass_count = ?,
266
+ confidence = ?,
267
+ description = ?,
268
+ critical_flows = ?,
269
+ test_patterns = ?,
270
+ related_files = ?,
271
+ last_verified_at = datetime('now')
272
+ WHERE id = ?
273
+ `).run(
274
+ newCount,
275
+ newConfidence,
276
+ description,
277
+ JSON.stringify(flows),
278
+ JSON.stringify(tests),
279
+ JSON.stringify(changedFiles.slice(0, 10)),
280
+ existing.id
281
+ )
282
+ );
283
+
284
+ return { id: existing.id, module: module_, pass_count: newCount, confidence: newConfidence, updated: true };
285
+ }
286
+
287
+ // Create new behavior
288
+ const id = `pb_${module_}_${Date.now()}`;
289
+ safe(() =>
290
+ db.prepare(`
291
+ INSERT OR IGNORE INTO protected_behaviors
292
+ (id, module, description, critical_flows, test_patterns, related_files, pass_count, confidence)
293
+ VALUES (?, ?, ?, ?, ?, ?, 1, 'MEDIA')
294
+ `).run(
295
+ id, module_, description,
296
+ JSON.stringify(flows),
297
+ JSON.stringify(tests),
298
+ JSON.stringify(changedFiles.slice(0, 10))
299
+ )
300
+ );
301
+
302
+ return { id, module: module_, pass_count: 1, confidence: 'MEDIA', created: true };
303
+ }
304
+
305
+ /**
306
+ * STEP after TDD Gate — verify protected behaviors weren't silently broken.
307
+ * Compares current test output against registered behaviors.
308
+ */
309
+ function verifyAfterTDD(db, testOutput, changedFiles, projectRoot) {
310
+ ensureSchema(db);
311
+ projectRoot = projectRoot || process.cwd();
312
+
313
+ const related = findRelatedBehaviors(db, changedFiles);
314
+ if (related.length === 0) return { passed: true };
315
+
316
+ const clean = (testOutput || '').replace(/\x1b\[[0-9;]*[mGKHF]/g, '');
317
+ const violations = [];
318
+
319
+ related.forEach(behavior => {
320
+ const patterns = parseJ(behavior.test_patterns, []);
321
+ patterns.forEach(pattern => {
322
+ // Check if this test file appears in the output as failed
323
+ const failPattern = new RegExp(`FAIL.*${pattern.replace('.', '\\.')}`, 'i');
324
+ if (failPattern.test(clean)) {
325
+ violations.push({
326
+ behavior_id: behavior.id,
327
+ behavior: behavior.description,
328
+ module: behavior.module,
329
+ test_pattern: pattern,
330
+ });
331
+
332
+ // Record violation
333
+ const vid = `iv_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
334
+ safe(() =>
335
+ db.prepare(`
336
+ INSERT OR IGNORE INTO invariant_violations
337
+ (id, behavior_id, changed_files, failed_tests, description)
338
+ VALUES (?, ?, ?, ?, ?)
339
+ `).run(
340
+ vid,
341
+ behavior.id,
342
+ JSON.stringify(changedFiles),
343
+ JSON.stringify([pattern]),
344
+ `${pattern} failed after changes to ${changedFiles.join(', ')}`
345
+ )
346
+ );
347
+
348
+ // Mark behavior as violated
349
+ safe(() =>
350
+ db.prepare(`UPDATE protected_behaviors SET status = 'violated' WHERE id = ?`)
351
+ .run(behavior.id)
352
+ );
353
+ } else {
354
+ // Test still passing — update verified timestamp
355
+ safe(() =>
356
+ db.prepare(`UPDATE protected_behaviors SET last_verified_at = datetime('now') WHERE id = ?`)
357
+ .run(behavior.id)
358
+ );
359
+ }
360
+ });
361
+ });
362
+
363
+ if (violations.length > 0) {
364
+ return {
365
+ passed: false,
366
+ violations,
367
+ message: `⚠️ REGRESSION DETECTED: ${violations.length} previously-healthy behavior(s) broken:\n` +
368
+ violations.map(v => ` [${v.module}] "${v.behavior}" — ${v.test_pattern} now failing`).join('\n'),
369
+ };
370
+ }
371
+
372
+ return { passed: true, verified: related.length };
373
+ }
374
+
375
+ /**
376
+ * Status report — akdd regression status
377
+ */
378
+ function regressionStatus(db) {
379
+ ensureSchema(db);
380
+
381
+ const behaviors = safe(() => db.prepare(`SELECT * FROM protected_behaviors ORDER BY confidence DESC, pass_count DESC`).all()) || [];
382
+ const violations = safe(() => db.prepare(`SELECT * FROM invariant_violations WHERE fixed_at IS NULL ORDER BY created_at DESC`).all()) || [];
383
+
384
+ const high = behaviors.filter(b => b.confidence === 'HIGH' && b.status === 'active');
385
+ const media = behaviors.filter(b => b.confidence === 'MEDIA' && b.status === 'active');
386
+ const violated= behaviors.filter(b => b.status === 'violated');
387
+
388
+ const lines = [
389
+ '',
390
+ '═══════════════════════════════════════════════════',
391
+ ' Regression Guard — Protected Behaviors',
392
+ '═══════════════════════════════════════════════════',
393
+ ` HIGH (${high.length}): fully protected behaviors`,
394
+ ` MEDIA (${media.length}): emerging behaviors (< 5 cycles)`,
395
+ ` VIOLATED (${violated.length}): currently broken`,
396
+ ` Open violations: ${violations.length}`,
397
+ '',
398
+ ];
399
+
400
+ if (high.length > 0) {
401
+ lines.push(' ── HIGH confidence ────────────────────────────');
402
+ high.forEach(b => lines.push(` ✅ [${b.module}] ${b.description.substring(0, 60)} (${b.pass_count} cycles)`));
403
+ }
404
+
405
+ if (violated.length > 0) {
406
+ lines.push('\n ── VIOLATED ────────────────────────────────────');
407
+ violated.forEach(b => lines.push(` ❌ [${b.module}] ${b.description.substring(0, 60)}`));
408
+ }
409
+
410
+ if (media.length > 0) {
411
+ lines.push('\n ── MEDIA confidence ────────────────────────────');
412
+ media.forEach(b => lines.push(` 🔶 [${b.module}] ${b.description.substring(0, 60)} (${b.pass_count} cycles)`));
413
+ }
414
+
415
+ lines.push('═══════════════════════════════════════════════════\n');
416
+ return lines.join('\n');
417
+ }
418
+
419
+ /**
420
+ * Deprecate a behavior manually — akdd behaviors deprecate <id>
421
+ */
422
+ function deprecateBehavior(db, id) {
423
+ ensureSchema(db);
424
+ const result = safe(() =>
425
+ db.prepare(`UPDATE protected_behaviors SET status = 'deprecated' WHERE id = ?`).run(id)
426
+ );
427
+ return result?.changes > 0;
428
+ }
429
+
430
+ /**
431
+ * Fix a violation — called after the dev confirms the regression was intentional
432
+ */
433
+ function fixViolation(db, behaviorId) {
434
+ ensureSchema(db);
435
+ safe(() => {
436
+ db.prepare(`UPDATE invariant_violations SET fixed_at = datetime('now') WHERE behavior_id = ? AND fixed_at IS NULL`).run(behaviorId);
437
+ db.prepare(`UPDATE protected_behaviors SET status = 'active', pass_count = 1, confidence = 'MEDIA' WHERE id = ?`).run(behaviorId);
438
+ });
439
+ }
440
+
441
+ // ─── CLI ──────────────────────────────────────────────────────────────────────
442
+
443
+ if (require.main === module) {
444
+ const cmd = process.argv[2] || 'status';
445
+ const args = process.argv.slice(3);
446
+
447
+ const dbPath = path.join(process.cwd(), '.agentic/memoria.db');
448
+ if (!require('fs').existsSync(dbPath)) {
449
+ console.log('No .agentic/memoria.db found. Run: akdd init');
450
+ process.exit(0);
451
+ }
452
+
453
+ const DB = new (require('better-sqlite3'))(dbPath);
454
+ ensureSchema(DB);
455
+
456
+ switch(cmd) {
457
+ case 'status':
458
+ console.log(regressionStatus(DB));
459
+ break;
460
+
461
+ case 'check': {
462
+ const files = args;
463
+ if (!files.length) { console.log('Usage: regression-guard.cjs check <file1> <file2>...'); break; }
464
+ const result = checkBeforeBuild(DB, files, process.cwd());
465
+ if (!result.passed) {
466
+ console.log(result.message);
467
+ process.exit(1);
468
+ }
469
+ console.log(result.message || '✅ REGRESSION GUARD PASS');
470
+ break;
471
+ }
472
+
473
+ case 'register': {
474
+ const module = args[0] || 'global';
475
+ const files = args.slice(1);
476
+ const result = registerBehavior(DB, { module, files, projectRoot: process.cwd() });
477
+ if (result) {
478
+ console.log(`✅ Behavior ${result.created ? 'created' : 'updated'}: [${result.module}] ${result.confidence} (${result.pass_count} cycles)`);
479
+ }
480
+ break;
481
+ }
482
+
483
+ case 'deprecate': {
484
+ const id = args[0];
485
+ if (!id) { console.log('Usage: regression-guard.cjs deprecate <behavior-id>'); break; }
486
+ deprecateBehavior(DB, id);
487
+ console.log(`✅ Behavior ${id} deprecated`);
488
+ break;
489
+ }
490
+
491
+ case 'fix': {
492
+ const id = args[0];
493
+ if (!id) { console.log('Usage: regression-guard.cjs fix <behavior-id>'); break; }
494
+ fixViolation(DB, id);
495
+ console.log(`✅ Violation fixed, behavior reset to MEDIA`);
496
+ break;
497
+ }
498
+
499
+ default:
500
+ console.log('Commands: status | check <files> | register <module> <files> | deprecate <id> | fix <id>');
501
+ }
502
+
503
+ DB.close();
504
+ }
505
+
506
+ module.exports = {
507
+ ensureSchema,
508
+ checkBeforeBuild,
509
+ registerBehavior,
510
+ verifyAfterTDD,
511
+ regressionStatus,
512
+ deprecateBehavior,
513
+ fixViolation,
514
+ };
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Security Gate — Step 3 of the enhanced aa: pipeline
3
+ *
4
+ * Brecha 2 cerrada: cuando el cambio toca archivos CRITICAL o SENSITIVE,
5
+ * corre audit: seguridad automáticamente ANTES del Build step.
6
+ *
7
+ * Si el audit detecta vulnerabilidades → STOP con reporte.
8
+ * Si el archivo es CRITICAL pero no hay vulnerabilidades → WARN + continúa.
9
+ */
10
+
11
+ 'use strict';
12
+
13
+ const path = require('path');
14
+ const fs = require('fs');
15
+
16
+ // Security patterns to check in changed files
17
+ const SECURITY_PATTERNS = [
18
+ {
19
+ pattern: /tenant_id.*query|query.*tenant_id/i,
20
+ negate: true, // flag when tenant_id is NOT in query
21
+ check: (content, filename) => {
22
+ // Routes that query data should always filter by tenant_id
23
+ if (!filename.includes('route') && !filename.includes('handler')) return null;
24
+ const hasQuery = /prisma\.\w+\.find(Many|First|Unique)/i.test(content);
25
+ if (!hasQuery) return null;
26
+ const hasTenantFilter = /tenant_id.*:\s*(?:tenantId|request\.authUser|req\.user)/i.test(content);
27
+ if (!hasTenantFilter) {
28
+ return {
29
+ type: 'CROSS_TENANT_RISK',
30
+ severity: 'CRITICAL',
31
+ message: 'Database query without tenant_id filter — potential cross-tenant data access',
32
+ file: filename,
33
+ };
34
+ }
35
+ return null;
36
+ },
37
+ },
38
+ {
39
+ check: (content, filename) => {
40
+ // Detect "role='admin'" protecting cross-tenant access
41
+ // admin in this system = tenant-level, not platform-level
42
+ const crossTenantWithAdmin = /tenant_id.*param|param.*tenant_id/i.test(content) &&
43
+ /role.*admin|admin.*role/i.test(content) &&
44
+ !/superadmin/i.test(content);
45
+ if (crossTenantWithAdmin) {
46
+ return {
47
+ type: 'ADMIN_CROSS_TENANT',
48
+ severity: 'HIGH',
49
+ message: 'Cross-tenant access protected only by admin role — admin is tenant-scoped, use superadmin for platform-level operations',
50
+ file: filename,
51
+ };
52
+ }
53
+ return null;
54
+ },
55
+ },
56
+ {
57
+ check: (content, filename) => {
58
+ // JWT verification bypass
59
+ const bypass = /jwt\.(decode|verify)\s*\(/i.test(content) &&
60
+ /debug|bypass|skip/i.test(content);
61
+ if (bypass) {
62
+ return {
63
+ type: 'JWT_BYPASS',
64
+ severity: 'CRITICAL',
65
+ message: 'Potential JWT verification bypass detected',
66
+ file: filename,
67
+ };
68
+ }
69
+ return null;
70
+ },
71
+ },
72
+ {
73
+ check: (content, filename) => {
74
+ // Missing return after reply.status in auth middleware
75
+ if (!filename.includes('auth') && !filename.includes('middleware')) return null;
76
+ const hasReplyWithoutReturn = /reply\.status\(\d+\)\.send\(/.test(content) &&
77
+ !/return reply\.status/.test(content);
78
+ if (hasReplyWithoutReturn) {
79
+ return {
80
+ type: 'MISSING_RETURN',
81
+ severity: 'HIGH',
82
+ message: 'reply.status().send() without return — request may continue after auth rejection',
83
+ file: filename,
84
+ };
85
+ }
86
+ return null;
87
+ },
88
+ },
89
+ ];
90
+
91
+ function classifyFileRisk(filePath) {
92
+ const fp = filePath.replace(/\\/g, '/').toLowerCase();
93
+ if (fp.includes('auth') || fp.includes('middleware') || fp.includes('.env') || fp.includes('secret')) return 'CRITICAL';
94
+ if (fp.includes('routes/') || fp.includes('lib/') || fp.includes('prisma')) return 'SENSITIVE';
95
+ if (fp.includes('tests/') || fp.includes('utils/') || fp.includes('constants')) return 'FREE';
96
+ return 'NORMAL';
97
+ }
98
+
99
+ function scanFile(filePath, projectRoot) {
100
+ const full = path.isAbsolute(filePath) ? filePath : path.join(projectRoot, filePath);
101
+ if (!fs.existsSync(full)) return [];
102
+
103
+ const content = fs.readFileSync(full, 'utf8');
104
+ const filename = path.basename(full);
105
+ const findings = [];
106
+
107
+ SECURITY_PATTERNS.forEach(p => {
108
+ const result = p.check(content, filename);
109
+ if (result) findings.push(result);
110
+ });
111
+
112
+ return findings;
113
+ }
114
+
115
+ function runSecurityGate(files, projectRoot) {
116
+ projectRoot = projectRoot || process.cwd();
117
+ const allFindings = [];
118
+ const criticalFiles = [];
119
+
120
+ (files || []).forEach(file => {
121
+ const risk = classifyFileRisk(file);
122
+ if (risk === 'CRITICAL' || risk === 'SENSITIVE') {
123
+ criticalFiles.push({ file, risk });
124
+ const findings = scanFile(file, projectRoot);
125
+ allFindings.push(...findings);
126
+ }
127
+ });
128
+
129
+ if (criticalFiles.length === 0) {
130
+ return { passed: true, reason: 'No critical/sensitive files in changeset' };
131
+ }
132
+
133
+ const critical = allFindings.filter(f => f.severity === 'CRITICAL');
134
+ const high = allFindings.filter(f => f.severity === 'HIGH');
135
+
136
+ if (critical.length > 0) {
137
+ return {
138
+ passed: false,
139
+ findings: allFindings,
140
+ critical_files: criticalFiles,
141
+ message: `SECURITY GATE STOP: ${critical.length} critical finding(s):\n` +
142
+ critical.map(f => ` 🔴 [${f.type}] ${f.message} (${f.file})`).join('\n'),
143
+ };
144
+ }
145
+
146
+ return {
147
+ passed: true,
148
+ warn: high.length > 0,
149
+ findings: allFindings,
150
+ critical_files: criticalFiles,
151
+ message: high.length > 0
152
+ ? `SECURITY GATE WARN: ${high.length} high finding(s) — review before deploying:\n` +
153
+ high.map(f => ` 🟡 [${f.type}] ${f.message}`).join('\n')
154
+ : `SECURITY GATE PASS — ${criticalFiles.length} sensitive file(s) scanned, no issues found`,
155
+ };
156
+ }
157
+
158
+ if (require.main === module) {
159
+ const files = process.argv.slice(2);
160
+ if (!files.length) {
161
+ console.log('Usage: node security-gate.cjs file1.ts file2.ts ...');
162
+ process.exit(0);
163
+ }
164
+ const result = runSecurityGate(files, process.cwd());
165
+ console.log(result.passed ? (result.warn ? '⚠️ ' : '✅ ') + result.message : '🛑 ' + result.message);
166
+ process.exit(result.passed ? 0 : 1);
167
+ }
168
+
169
+ module.exports = { runSecurityGate, classifyFileRisk };