agileflow 2.98.1 → 2.99.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.99.0] - 2026-02-08
11
+
12
+ ### Added
13
+ - Security Hardening Phase 2 - path traversal, auth, CORS, ReDoS, rate limiting
14
+
10
15
  ## [2.98.1] - 2026-02-07
11
16
 
12
17
  ### Added
package/lib/api-routes.js CHANGED
@@ -26,6 +26,10 @@ const {
26
26
  } = require('./paths');
27
27
  const { SessionRegistry } = require('./session-registry');
28
28
  const { getTaskRegistry } = require('../scripts/lib/task-registry');
29
+ const { validatePath } = require('./validate-paths');
30
+
31
+ // Allow-list regex for resource IDs (epics, stories, tasks, sessions)
32
+ const SAFE_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
29
33
 
30
34
  /**
31
35
  * Get API route handlers
@@ -139,6 +143,10 @@ async function getSessions(sessionRegistry, cache) {
139
143
  * GET /api/sessions/:id - Get session by ID
140
144
  */
141
145
  async function getSessionById(sessionRegistry, id, cache) {
146
+ if (!SAFE_ID_PATTERN.test(id)) {
147
+ return { error: 'Invalid session ID format' };
148
+ }
149
+
142
150
  const cacheKey = `session-${id}`;
143
151
  const cached = cache.get(cacheKey);
144
152
  if (cached) return cached;
@@ -183,7 +191,7 @@ function getStatus(rootDir, cache) {
183
191
  cache.set(cacheKey, result);
184
192
  return result;
185
193
  } catch (error) {
186
- return { error: 'Failed to parse status file', message: error.message };
194
+ return { error: 'Failed to parse status file' };
187
195
  }
188
196
  }
189
197
 
@@ -226,7 +234,7 @@ function getTasks(rootDir, queryParams, cache) {
226
234
  cache.set(cacheKey, result);
227
235
  return result;
228
236
  } catch (error) {
229
- return { error: 'Failed to load tasks', message: error.message };
237
+ return { error: 'Failed to load tasks' };
230
238
  }
231
239
  }
232
240
 
@@ -234,6 +242,10 @@ function getTasks(rootDir, queryParams, cache) {
234
242
  * GET /api/tasks/:id - Get task by ID
235
243
  */
236
244
  function getTaskById(rootDir, id, cache) {
245
+ if (!SAFE_ID_PATTERN.test(id)) {
246
+ return { error: 'Invalid task ID format' };
247
+ }
248
+
237
249
  const cacheKey = `task-${id}`;
238
250
  const cached = cache.get(cacheKey);
239
251
  if (cached) return cached;
@@ -249,7 +261,7 @@ function getTaskById(rootDir, id, cache) {
249
261
  cache.set(cacheKey, task);
250
262
  return task;
251
263
  } catch (error) {
252
- return { error: 'Failed to load task', message: error.message };
264
+ return { error: 'Failed to load task' };
253
265
  }
254
266
  }
255
267
 
@@ -300,7 +312,7 @@ async function getBusMessages(rootDir, queryParams, cache) {
300
312
  cache.set(cacheKey, result);
301
313
  return result;
302
314
  } catch (error) {
303
- return { error: 'Failed to read bus log', message: error.message };
315
+ return { error: 'Failed to read bus log' };
304
316
  }
305
317
  }
306
318
 
@@ -448,7 +460,7 @@ function getEpics(rootDir, cache) {
448
460
  cache.set(cacheKey, result);
449
461
  return result;
450
462
  } catch (error) {
451
- return { error: 'Failed to list epics', message: error.message };
463
+ return { error: 'Failed to list epics' };
452
464
  }
453
465
  }
454
466
 
@@ -456,6 +468,10 @@ function getEpics(rootDir, cache) {
456
468
  * GET /api/epics/:id - Get epic by ID
457
469
  */
458
470
  function getEpicById(rootDir, id, cache) {
471
+ if (!SAFE_ID_PATTERN.test(id)) {
472
+ return { error: 'Invalid epic ID format' };
473
+ }
474
+
459
475
  const cacheKey = `epic-${id}`;
460
476
  const cached = cache.get(cacheKey);
461
477
  if (cached) return cached;
@@ -479,7 +495,7 @@ function getEpicById(rootDir, id, cache) {
479
495
  cache.set(cacheKey, result);
480
496
  return result;
481
497
  } catch (error) {
482
- return { error: 'Failed to read epic', message: error.message };
498
+ return { error: 'Failed to read epic' };
483
499
  }
484
500
  }
485
501
 
@@ -533,7 +549,7 @@ function getStories(rootDir, queryParams, cache) {
533
549
 
534
550
  return { stories: [], count: 0, timestamp: new Date().toISOString() };
535
551
  } catch (error) {
536
- return { error: 'Failed to list stories', message: error.message };
552
+ return { error: 'Failed to list stories' };
537
553
  }
538
554
  }
539
555
 
@@ -541,6 +557,10 @@ function getStories(rootDir, queryParams, cache) {
541
557
  * GET /api/stories/:id - Get story by ID
542
558
  */
543
559
  function getStoryById(rootDir, id, cache) {
560
+ if (!SAFE_ID_PATTERN.test(id)) {
561
+ return { error: 'Invalid story ID format' };
562
+ }
563
+
544
564
  const cacheKey = `story-${id}`;
545
565
  const cached = cache.get(cacheKey);
546
566
  if (cached) return cached;
@@ -569,7 +589,7 @@ function getStoryById(rootDir, id, cache) {
569
589
 
570
590
  return { error: 'Story not found', id };
571
591
  } catch (error) {
572
- return { error: 'Failed to read story', message: error.message };
592
+ return { error: 'Failed to read story' };
573
593
  }
574
594
  }
575
595
 
package/lib/api-server.js CHANGED
@@ -87,13 +87,31 @@ function createApiServer(options = {}) {
87
87
  // Get route handlers
88
88
  const routes = getApiRoutes(rootDir, cache);
89
89
 
90
+ // Localhost CORS allowlist
91
+ const ALLOWED_ORIGINS = [
92
+ `http://localhost:${port}`,
93
+ `http://127.0.0.1:${port}`,
94
+ 'http://localhost:3000',
95
+ 'http://127.0.0.1:3000',
96
+ 'http://localhost:5173',
97
+ 'http://127.0.0.1:5173',
98
+ ];
99
+
90
100
  // Create HTTP server
91
101
  const server = http.createServer(async (req, res) => {
92
- // CORS headers for browser access
93
- res.setHeader('Access-Control-Allow-Origin', '*');
102
+ // Security headers
103
+ res.setHeader('X-Content-Type-Options', 'nosniff');
104
+ res.setHeader('X-Frame-Options', 'DENY');
105
+ res.setHeader('Cache-Control', 'no-store');
106
+ res.setHeader('Content-Type', 'application/json');
107
+
108
+ // CORS - restrict to localhost origins
109
+ const origin = req.headers.origin;
110
+ if (origin && ALLOWED_ORIGINS.some(allowed => origin === allowed)) {
111
+ res.setHeader('Access-Control-Allow-Origin', origin);
112
+ }
94
113
  res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
95
114
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
96
- res.setHeader('Content-Type', 'application/json');
97
115
 
98
116
  // Handle preflight
99
117
  if (req.method === 'OPTIONS') {
@@ -155,7 +173,6 @@ function createApiServer(options = {}) {
155
173
  res.end(
156
174
  JSON.stringify({
157
175
  error: 'Internal server error',
158
- message: error.message,
159
176
  })
160
177
  );
161
178
  }
@@ -42,6 +42,7 @@ const {
42
42
  serializeMessage,
43
43
  } = require('./dashboard-protocol');
44
44
  const { getProjectRoot, isAgileflowProject, getAgentsDir } = require('./paths');
45
+ const { validatePath } = require('./validate-paths');
45
46
  const { execFileSync, spawn } = require('child_process');
46
47
  const os = require('os');
47
48
 
@@ -67,7 +68,18 @@ function getAutomationRunner(rootDir) {
67
68
 
68
69
  // Default configuration
69
70
  const DEFAULT_PORT = 8765;
70
- const DEFAULT_HOST = '0.0.0.0'; // Allow external connections (for tunnels)
71
+ const DEFAULT_HOST = '127.0.0.1'; // Localhost only for security
72
+
73
+ // Session lifecycle
74
+ const SESSION_TIMEOUT_MS = 4 * 60 * 60 * 1000; // 4 hours
75
+ const SESSION_CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
76
+
77
+ // Rate limiting (token bucket)
78
+ const RATE_LIMIT_TOKENS = 100; // max messages per second
79
+ const RATE_LIMIT_REFILL_MS = 1000; // refill interval
80
+
81
+ // Sensitive env var patterns to strip from terminal spawn
82
+ const SENSITIVE_ENV_PATTERNS = /SECRET|TOKEN|PASSWORD|CREDENTIAL|API_KEY|PRIVATE_KEY|AUTH/i;
71
83
 
72
84
  // WebSocket magic GUID for handshake
73
85
  const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
@@ -83,7 +95,42 @@ class DashboardSession {
83
95
  this.messages = [];
84
96
  this.state = 'connected';
85
97
  this.lastActivity = new Date();
98
+ this.createdAt = new Date();
86
99
  this.metadata = {};
100
+
101
+ // Token bucket rate limiter
102
+ this._rateTokens = RATE_LIMIT_TOKENS;
103
+ this._rateLastRefill = Date.now();
104
+ }
105
+
106
+ /**
107
+ * Check if session has expired
108
+ * @returns {boolean}
109
+ */
110
+ isExpired() {
111
+ return Date.now() - this.lastActivity.getTime() > SESSION_TIMEOUT_MS;
112
+ }
113
+
114
+ /**
115
+ * Rate-limit incoming messages (token bucket)
116
+ * @returns {boolean} true if allowed, false if rate-limited
117
+ */
118
+ checkRateLimit() {
119
+ const now = Date.now();
120
+ const elapsed = now - this._rateLastRefill;
121
+
122
+ // Refill tokens based on elapsed time
123
+ if (elapsed >= RATE_LIMIT_REFILL_MS) {
124
+ this._rateTokens = RATE_LIMIT_TOKENS;
125
+ this._rateLastRefill = now;
126
+ }
127
+
128
+ if (this._rateTokens <= 0) {
129
+ return false;
130
+ }
131
+
132
+ this._rateTokens--;
133
+ return true;
87
134
  }
88
135
 
89
136
  /**
@@ -167,10 +214,24 @@ class TerminalInstance {
167
214
  /**
168
215
  * Start the terminal process
169
216
  */
217
+ /**
218
+ * Get a filtered copy of environment variables with secrets removed
219
+ */
220
+ _getFilteredEnv() {
221
+ const filtered = {};
222
+ for (const [key, value] of Object.entries(process.env)) {
223
+ if (!SENSITIVE_ENV_PATTERNS.test(key)) {
224
+ filtered[key] = value;
225
+ }
226
+ }
227
+ return filtered;
228
+ }
229
+
170
230
  start() {
171
231
  try {
172
232
  // Try to use node-pty for proper PTY support
173
233
  const pty = require('node-pty');
234
+ const filteredEnv = this._getFilteredEnv();
174
235
 
175
236
  this.pty = pty.spawn(this.shell, [], {
176
237
  name: 'xterm-256color',
@@ -178,7 +239,7 @@ class TerminalInstance {
178
239
  rows: this.rows,
179
240
  cwd: this.cwd,
180
241
  env: {
181
- ...process.env,
242
+ ...filteredEnv,
182
243
  TERM: 'xterm-256color',
183
244
  COLORTERM: 'truecolor',
184
245
  },
@@ -209,11 +270,13 @@ class TerminalInstance {
209
270
  */
210
271
  startBasicShell() {
211
272
  try {
273
+ const filteredEnv = this._getFilteredEnv();
274
+
212
275
  // Use bash with interactive flag for better compatibility
213
276
  this.pty = spawn(this.shell, ['-i'], {
214
277
  cwd: this.cwd,
215
278
  env: {
216
- ...process.env,
279
+ ...filteredEnv,
217
280
  TERM: 'dumb',
218
281
  PS1: '\\w $ ', // Simple prompt
219
282
  },
@@ -443,8 +506,11 @@ class DashboardServer extends EventEmitter {
443
506
  this.port = options.port || DEFAULT_PORT;
444
507
  this.host = options.host || DEFAULT_HOST;
445
508
  this.projectRoot = options.projectRoot || getProjectRoot();
446
- this.apiKey = options.apiKey || null;
447
- this.requireAuth = options.requireAuth || false;
509
+
510
+ // Auth is on by default - auto-generate key if not provided
511
+ // Set requireAuth: false explicitly to disable
512
+ this.requireAuth = options.requireAuth !== false;
513
+ this.apiKey = options.apiKey || (this.requireAuth ? crypto.randomBytes(32).toString('hex') : null);
448
514
 
449
515
  // Session management
450
516
  this.sessions = new Map();
@@ -460,6 +526,9 @@ class DashboardServer extends EventEmitter {
460
526
  // Inbox management
461
527
  this._inbox = new Map(); // itemId -> InboxItem
462
528
 
529
+ // Session cleanup interval
530
+ this._cleanupInterval = null;
531
+
463
532
  // HTTP server for WebSocket upgrade
464
533
  this.httpServer = null;
465
534
 
@@ -544,10 +613,17 @@ class DashboardServer extends EventEmitter {
544
613
  */
545
614
  start() {
546
615
  return new Promise((resolve, reject) => {
616
+ const securityHeaders = {
617
+ 'Content-Type': 'application/json',
618
+ 'X-Content-Type-Options': 'nosniff',
619
+ 'X-Frame-Options': 'DENY',
620
+ 'Cache-Control': 'no-store',
621
+ };
622
+
547
623
  this.httpServer = http.createServer((req, res) => {
548
624
  // Simple health check endpoint
549
625
  if (req.url === '/health') {
550
- res.writeHead(200, { 'Content-Type': 'application/json' });
626
+ res.writeHead(200, securityHeaders);
551
627
  res.end(
552
628
  JSON.stringify({
553
629
  status: 'ok',
@@ -560,12 +636,12 @@ class DashboardServer extends EventEmitter {
560
636
 
561
637
  // Info endpoint
562
638
  if (req.url === '/') {
563
- res.writeHead(200, { 'Content-Type': 'application/json' });
639
+ res.writeHead(200, securityHeaders);
564
640
  res.end(
565
641
  JSON.stringify({
566
642
  name: 'AgileFlow Dashboard Server',
567
643
  version: '1.0.0',
568
- ws: `ws://${this.host === '0.0.0.0' ? 'localhost' : this.host}:${this.port}`,
644
+ ws: `ws://${this.host === '127.0.0.1' ? 'localhost' : this.host}:${this.port}`,
569
645
  sessions: this.sessions.size,
570
646
  })
571
647
  );
@@ -597,9 +673,19 @@ class DashboardServer extends EventEmitter {
597
673
  console.log(` WebSocket: ${wsUrl}`);
598
674
  console.log(` Health: ${url}/health`);
599
675
  console.log(` Project: ${this.projectRoot}`);
600
- console.log(` Auth: ${this.requireAuth ? 'Required' : 'Not required'}\n`);
676
+ console.log(` Auth: ${this.requireAuth ? 'Required' : 'Not required'}`);
677
+ if (this.requireAuth && this.apiKey) {
678
+ console.log(` API Key: ${this.apiKey.slice(0, 8)}...`);
679
+ }
680
+ console.log('');
681
+
682
+ // Start session cleanup interval
683
+ this._cleanupInterval = setInterval(() => {
684
+ this._cleanupExpiredSessions();
685
+ }, SESSION_CLEANUP_INTERVAL_MS);
686
+ this._cleanupInterval.unref();
601
687
 
602
- resolve({ url, wsUrl });
688
+ resolve({ url, wsUrl, apiKey: this.apiKey });
603
689
  });
604
690
  });
605
691
  }
@@ -617,15 +703,37 @@ class DashboardServer extends EventEmitter {
617
703
  // Check API key if required
618
704
  if (this.requireAuth && this.apiKey) {
619
705
  const authHeader = req.headers['x-api-key'] || req.headers.authorization;
620
- const providedKey = authHeader?.replace('Bearer ', '');
706
+ const providedKey = authHeader?.replace('Bearer ', '') || '';
621
707
 
622
- if (providedKey !== this.apiKey) {
708
+ // Use timing-safe comparison to prevent timing attacks
709
+ const keyBuffer = Buffer.from(this.apiKey, 'utf8');
710
+ const providedBuffer = Buffer.from(providedKey, 'utf8');
711
+ if (keyBuffer.length !== providedBuffer.length ||
712
+ !crypto.timingSafeEqual(keyBuffer, providedBuffer)) {
623
713
  socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
624
714
  socket.destroy();
625
715
  return;
626
716
  }
627
717
  }
628
718
 
719
+ // Check WebSocket origin against localhost allowlist
720
+ const origin = req.headers.origin;
721
+ if (origin) {
722
+ const LOCALHOST_ORIGINS = [
723
+ 'http://localhost', 'https://localhost',
724
+ 'http://127.0.0.1', 'https://127.0.0.1',
725
+ 'http://[::1]', 'https://[::1]',
726
+ ];
727
+ const isLocalhost = LOCALHOST_ORIGINS.some(
728
+ allowed => origin === allowed || origin.startsWith(allowed + ':')
729
+ );
730
+ if (!isLocalhost) {
731
+ socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
732
+ socket.destroy();
733
+ return;
734
+ }
735
+ }
736
+
629
737
  // Complete WebSocket handshake
630
738
  const key = req.headers['sec-websocket-key'];
631
739
  const acceptKey = crypto
@@ -750,6 +858,12 @@ class DashboardServer extends EventEmitter {
750
858
  * Handle incoming message from dashboard
751
859
  */
752
860
  handleMessage(session, data) {
861
+ // Rate limit incoming messages
862
+ if (!session.checkRateLimit()) {
863
+ session.send(createError('RATE_LIMITED', 'Too many messages, please slow down'));
864
+ return;
865
+ }
866
+
753
867
  const message = parseInboundMessage(data);
754
868
  if (!message) {
755
869
  session.send(createError('INVALID_MESSAGE', 'Failed to parse message'));
@@ -956,7 +1070,8 @@ class DashboardServer extends EventEmitter {
956
1070
  this.sendGitStatus(session);
957
1071
  session.send(createNotification('success', 'Git', `${type.replace('git_', '')} completed`));
958
1072
  } catch (error) {
959
- session.send(createError('GIT_ERROR', error.message));
1073
+ console.error('[Git Error]', error.message);
1074
+ session.send(createError('GIT_ERROR', 'Git operation failed'));
960
1075
  }
961
1076
  }
962
1077
 
@@ -1062,7 +1177,8 @@ class DashboardServer extends EventEmitter {
1062
1177
  })
1063
1178
  );
1064
1179
  } catch (error) {
1065
- session.send(createError('DIFF_ERROR', error.message));
1180
+ console.error('[Diff Error]', error.message);
1181
+ session.send(createError('DIFF_ERROR', 'Failed to get diff'));
1066
1182
  }
1067
1183
  }
1068
1184
 
@@ -1073,6 +1189,12 @@ class DashboardServer extends EventEmitter {
1073
1189
  * @returns {string} - The diff content
1074
1190
  */
1075
1191
  getFileDiff(filePath, staged = false) {
1192
+ // Validate filePath stays within project root
1193
+ const pathResult = validatePath(filePath, this.projectRoot, { allowSymlinks: true });
1194
+ if (!pathResult.ok) {
1195
+ return '';
1196
+ }
1197
+
1076
1198
  try {
1077
1199
  const diffArgs = staged ? ['diff', '--cached', '--', filePath] : ['diff', '--', filePath];
1078
1200
 
@@ -1144,10 +1266,21 @@ class DashboardServer extends EventEmitter {
1144
1266
  handleTerminalSpawn(session, message) {
1145
1267
  const { cols, rows, cwd } = message;
1146
1268
 
1269
+ // Validate cwd stays within project root
1270
+ let safeCwd = this.projectRoot;
1271
+ if (cwd) {
1272
+ const cwdResult = validatePath(cwd, this.projectRoot, { allowSymlinks: true });
1273
+ if (!cwdResult.ok) {
1274
+ session.send(createError('TERMINAL_ERROR', 'Working directory must be within project root'));
1275
+ return;
1276
+ }
1277
+ safeCwd = cwdResult.resolvedPath;
1278
+ }
1279
+
1147
1280
  const terminalId = this.terminalManager.createTerminal(session, {
1148
1281
  cols: cols || 80,
1149
1282
  rows: rows || 24,
1150
- cwd: cwd || this.projectRoot,
1283
+ cwd: safeCwd,
1151
1284
  });
1152
1285
 
1153
1286
  if (terminalId) {
@@ -1345,8 +1478,9 @@ class DashboardServer extends EventEmitter {
1345
1478
  // Refresh the list
1346
1479
  this.sendAutomationList(session);
1347
1480
  } catch (error) {
1348
- session.send(createError('AUTOMATION_ERROR', error.message));
1349
- session.send(createAutomationStatus(automationId, 'error', { error: error.message }));
1481
+ console.error('[Automation Error]', error.message);
1482
+ session.send(createError('AUTOMATION_ERROR', 'Automation execution failed'));
1483
+ session.send(createAutomationStatus(automationId, 'error', { error: 'Execution failed' }));
1350
1484
  }
1351
1485
  }
1352
1486
 
@@ -1432,6 +1566,18 @@ class DashboardServer extends EventEmitter {
1432
1566
  this.sendInboxList(session);
1433
1567
  }
1434
1568
 
1569
+ /**
1570
+ * Cleanup expired sessions
1571
+ */
1572
+ _cleanupExpiredSessions() {
1573
+ for (const [sessionId, session] of this.sessions) {
1574
+ if (session.isExpired()) {
1575
+ console.log(`[Session ${sessionId}] Expired (idle > ${SESSION_TIMEOUT_MS / 3600000}h)`);
1576
+ this.closeSession(sessionId);
1577
+ }
1578
+ }
1579
+ }
1580
+
1435
1581
  /**
1436
1582
  * Close a session
1437
1583
  */
@@ -1473,6 +1619,12 @@ class DashboardServer extends EventEmitter {
1473
1619
  */
1474
1620
  stop() {
1475
1621
  return new Promise(resolve => {
1622
+ // Clear cleanup interval
1623
+ if (this._cleanupInterval) {
1624
+ clearInterval(this._cleanupInterval);
1625
+ this._cleanupInterval = null;
1626
+ }
1627
+
1476
1628
  // Close all sessions
1477
1629
  for (const session of this.sessions.values()) {
1478
1630
  if (session.ws) {
@@ -1623,6 +1775,10 @@ module.exports = {
1623
1775
  stopDashboardServer,
1624
1776
  DEFAULT_PORT,
1625
1777
  DEFAULT_HOST,
1778
+ SESSION_TIMEOUT_MS,
1779
+ SESSION_CLEANUP_INTERVAL_MS,
1780
+ RATE_LIMIT_TOKENS,
1781
+ SENSITIVE_ENV_PATTERNS,
1626
1782
  encodeWebSocketFrame,
1627
1783
  decodeWebSocketFrame,
1628
1784
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agileflow",
3
- "version": "2.98.1",
3
+ "version": "2.99.0",
4
4
  "description": "AI-driven agile development system for Claude Code, Cursor, Windsurf, and more",
5
5
  "keywords": [
6
6
  "agile",
@@ -450,9 +450,15 @@ function createBashHook() {
450
450
 
451
451
  /**
452
452
  * Test command against a single pattern rule
453
+ * Skips patterns that fail ReDoS validation
453
454
  */
454
455
  function matchesPattern(command, rule) {
455
456
  try {
457
+ // Validate pattern for ReDoS safety before use
458
+ const validation = validatePattern(rule.pattern);
459
+ if (!validation.safe) {
460
+ return false;
461
+ }
456
462
  const flags = rule.flags || '';
457
463
  const regex = new RegExp(rule.pattern, flags);
458
464
  return regex.test(command);
@@ -517,6 +523,37 @@ function createBashHook() {
517
523
  };
518
524
  }
519
525
 
526
+ /**
527
+ * Detect ReDoS-vulnerable patterns (nested quantifiers)
528
+ *
529
+ * Checks for patterns like (a+)+ or (a*b?)* that cause
530
+ * catastrophic backtracking on pathological inputs.
531
+ *
532
+ * @param {string} pattern - Regex pattern string to validate
533
+ * @returns {{ safe: boolean, reason?: string }}
534
+ */
535
+ function validatePattern(pattern) {
536
+ if (!pattern || typeof pattern !== 'string') {
537
+ return { safe: false, reason: 'Empty or invalid pattern' };
538
+ }
539
+
540
+ // Detect nested quantifiers: a group with a quantifier containing inner quantifiers
541
+ // Matches patterns like (x+)*, (x+)+, (x*)+, (x+){2,}, etc.
542
+ const nestedQuantifierRe = /\([^)]*[+*][^)]*\)[+*{]/;
543
+ if (nestedQuantifierRe.test(pattern)) {
544
+ return { safe: false, reason: `Nested quantifier detected in: ${pattern}` };
545
+ }
546
+
547
+ // Try to compile the regex to catch syntax errors
548
+ try {
549
+ new RegExp(pattern);
550
+ } catch (e) {
551
+ return { safe: false, reason: `Invalid regex: ${e.message}` };
552
+ }
553
+
554
+ return { safe: true };
555
+ }
556
+
520
557
  module.exports = {
521
558
  c,
522
559
  findProjectRoot,
@@ -529,6 +566,7 @@ module.exports = {
529
566
  parseBashPatterns,
530
567
  parsePathPatterns,
531
568
  validatePathAgainstPatterns,
569
+ validatePattern,
532
570
  createPathHook,
533
571
  createBashHook,
534
572
  CONFIG_PATHS,
@@ -16,7 +16,7 @@ version: "1.0.0"
16
16
 
17
17
  bashToolPatterns:
18
18
  # ─── File System Destruction ───
19
- - pattern: '\brm\s+(-[rRf]+\s+)*/'
19
+ - pattern: '\brm\s+(-[rRf]+\s+)?/'
20
20
  reason: "rm with absolute path - could delete system files"
21
21
 
22
22
  - pattern: '\brm\s+-[rRf]*\s+\.\.'
@@ -25,7 +25,7 @@ bashToolPatterns:
25
25
  - pattern: '\brm\s+-rf\s+'
26
26
  reason: "Recursive force delete - extremely dangerous"
27
27
 
28
- - pattern: '\brmdir\s+(-p\s+)*/'
28
+ - pattern: '\brmdir\s+(-p\s+)?/'
29
29
  reason: "rmdir with absolute path"
30
30
 
31
31
  # ─── Git Destructive Operations ───