agileflow 2.98.0 → 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 +10 -0
- package/lib/api-routes.js +28 -8
- package/lib/api-server.js +21 -4
- package/lib/dashboard-server.js +173 -17
- package/package.json +1 -1
- package/scripts/agileflow-statusline.sh +17 -21
- package/scripts/claude-tmux.sh +65 -67
- package/scripts/lib/configure-features.js +90 -28
- package/scripts/lib/damage-control-utils.js +38 -0
- package/src/core/agents/code-reviewer.md +1 -3
- package/src/core/agents/error-analyzer.md +1 -4
- package/src/core/agents/logic-analyzer-edge.md +1 -3
- package/src/core/agents/logic-analyzer-flow.md +1 -3
- package/src/core/agents/logic-analyzer-invariant.md +1 -3
- package/src/core/agents/logic-analyzer-race.md +1 -3
- package/src/core/agents/logic-analyzer-type.md +1 -3
- package/src/core/agents/logic-consensus.md +1 -5
- package/src/core/experts/documentation/expertise.yaml +33 -0
- package/src/core/templates/damage-control-patterns.yaml +2 -2
- package/tools/cli/installers/core/installer.js +0 -89
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,16 @@ 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
|
+
|
|
15
|
+
## [2.98.1] - 2026-02-07
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
- Fix alias dedup, add claude flags config, trim status line
|
|
19
|
+
|
|
10
20
|
## [2.98.0] - 2026-02-07
|
|
11
21
|
|
|
12
22
|
### Fixed
|
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'
|
|
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'
|
|
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'
|
|
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'
|
|
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'
|
|
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'
|
|
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'
|
|
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'
|
|
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
|
-
//
|
|
93
|
-
res.setHeader('
|
|
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
|
}
|
package/lib/dashboard-server.js
CHANGED
|
@@ -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 = '
|
|
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
|
-
...
|
|
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
|
-
...
|
|
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
|
-
|
|
447
|
-
|
|
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,
|
|
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,
|
|
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 === '
|
|
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'}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
1349
|
-
session.send(
|
|
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
|
@@ -78,14 +78,14 @@ fmt_time_hm() {
|
|
|
78
78
|
# ============================================================================
|
|
79
79
|
# Default: all components enabled
|
|
80
80
|
SHOW_AGILEFLOW=true
|
|
81
|
-
SHOW_MODEL=
|
|
81
|
+
SHOW_MODEL=false
|
|
82
82
|
SHOW_STORY=true
|
|
83
83
|
SHOW_EPIC=true
|
|
84
84
|
SHOW_WIP=true
|
|
85
85
|
SHOW_CONTEXT=true
|
|
86
86
|
SHOW_CONTEXT_BAR=true
|
|
87
|
-
SHOW_SESSION_TIME=
|
|
88
|
-
SHOW_COST=
|
|
87
|
+
SHOW_SESSION_TIME=false
|
|
88
|
+
SHOW_COST=false
|
|
89
89
|
SHOW_GIT=true
|
|
90
90
|
|
|
91
91
|
# Check agileflow-metadata.json for component settings
|
|
@@ -668,21 +668,6 @@ if [ "$SHOW_SESSION" = "true" ] && [ -n "$SESSION_INFO" ]; then
|
|
|
668
668
|
fi
|
|
669
669
|
fi
|
|
670
670
|
|
|
671
|
-
# Session health indicator (shows warning icon + count if issues exist)
|
|
672
|
-
if [ "$SHOW_SESSION" = "true" ]; then
|
|
673
|
-
SCRIPTS_DIR="$(dirname "$0")"
|
|
674
|
-
HEALTH_OUTPUT=$(node "$SCRIPTS_DIR/session-manager.js" health 2>/dev/null)
|
|
675
|
-
if [ -n "$HEALTH_OUTPUT" ]; then
|
|
676
|
-
HEALTH_ISSUES=$(echo "$HEALTH_OUTPUT" | jq -r '
|
|
677
|
-
(.uncommitted | length) + (.stale | length) + (.orphanedRegistry | length)
|
|
678
|
-
' 2>/dev/null)
|
|
679
|
-
if [ -n "$HEALTH_ISSUES" ] && [ "$HEALTH_ISSUES" != "0" ] && [ "$HEALTH_ISSUES" != "null" ]; then
|
|
680
|
-
[ -n "$OUTPUT" ] && OUTPUT="${OUTPUT} "
|
|
681
|
-
OUTPUT="${OUTPUT}${YELLOW}⚠${HEALTH_ISSUES}${RESET}"
|
|
682
|
-
fi
|
|
683
|
-
fi
|
|
684
|
-
fi
|
|
685
|
-
|
|
686
671
|
# Model with subtle styling (if enabled and available)
|
|
687
672
|
if [ "$SHOW_MODEL" = "true" ] && [ -n "$MODEL_DISPLAY" ]; then
|
|
688
673
|
[ -n "$OUTPUT" ] && OUTPUT="${OUTPUT}${SEP}"
|
|
@@ -694,9 +679,6 @@ if [ "$SHOW_STORY" = "true" ]; then
|
|
|
694
679
|
if [ -n "$STORY_DISPLAY" ]; then
|
|
695
680
|
[ -n "$OUTPUT" ] && OUTPUT="${OUTPUT}${SEP}"
|
|
696
681
|
OUTPUT="${OUTPUT}${STORY_DISPLAY}"
|
|
697
|
-
elif [ -n "$NEXT_STORY" ]; then
|
|
698
|
-
[ -n "$OUTPUT" ] && OUTPUT="${OUTPUT}${SEP}"
|
|
699
|
-
OUTPUT="${OUTPUT}${NEXT_STORY}"
|
|
700
682
|
fi
|
|
701
683
|
fi
|
|
702
684
|
|
|
@@ -747,4 +729,18 @@ if [ "$SHOW_GIT" = "true" ] && [ -n "$GIT_DISPLAY" ]; then
|
|
|
747
729
|
OUTPUT="${OUTPUT}${GIT_DISPLAY}"
|
|
748
730
|
fi
|
|
749
731
|
|
|
732
|
+
# Session health indicator (next to git branch)
|
|
733
|
+
if [ "$SHOW_SESSION" = "true" ]; then
|
|
734
|
+
SCRIPTS_DIR="$(dirname "$0")"
|
|
735
|
+
HEALTH_OUTPUT=$(node "$SCRIPTS_DIR/session-manager.js" health 2>/dev/null)
|
|
736
|
+
if [ -n "$HEALTH_OUTPUT" ]; then
|
|
737
|
+
HEALTH_ISSUES=$(echo "$HEALTH_OUTPUT" | jq -r '
|
|
738
|
+
(.uncommitted | length) + (.stale | length) + (.orphanedRegistry | length)
|
|
739
|
+
' 2>/dev/null)
|
|
740
|
+
if [ -n "$HEALTH_ISSUES" ] && [ "$HEALTH_ISSUES" != "0" ] && [ "$HEALTH_ISSUES" != "null" ]; then
|
|
741
|
+
OUTPUT="${OUTPUT} ${YELLOW}⚠${HEALTH_ISSUES}${RESET}"
|
|
742
|
+
fi
|
|
743
|
+
fi
|
|
744
|
+
fi
|
|
745
|
+
|
|
750
746
|
echo -e "$OUTPUT"
|