claudehq 1.0.3 → 1.0.5
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/lib/core/claude-events.js +2 -1
- package/lib/core/config.js +31 -17
- package/lib/core/event-bus.js +0 -18
- package/lib/index.js +181 -74
- package/lib/routes/api.js +0 -399
- package/lib/routes/orchestration.js +417 -0
- package/lib/routes/spawner.js +335 -0
- package/lib/sessions/manager.js +36 -9
- package/lib/spawner/index.js +51 -0
- package/lib/spawner/path-validator.js +366 -0
- package/lib/spawner/projects-manager.js +421 -0
- package/lib/spawner/session-spawner.js +1010 -0
- package/package.json +1 -1
- package/public/index.html +512 -1371
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path Validator - Security utilities for path validation
|
|
3
|
+
*
|
|
4
|
+
* Validates directory paths for safety before use in shell commands.
|
|
5
|
+
* Prevents command injection, path traversal, and other attacks.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const os = require('os');
|
|
11
|
+
|
|
12
|
+
// Characters that could be dangerous in shell commands
|
|
13
|
+
// Note: ~ is allowed as it's used for home directory expansion
|
|
14
|
+
const SHELL_METACHARACTERS = /[;&|`$(){}[\]<>\\!#*?\n\r\x00]/;
|
|
15
|
+
|
|
16
|
+
// Patterns that indicate path traversal attempts
|
|
17
|
+
const PATH_TRAVERSAL_PATTERNS = [
|
|
18
|
+
/\.\.\//, // ../
|
|
19
|
+
/\.\.\\/, // ..\
|
|
20
|
+
/\.\.$/, // ends with ..
|
|
21
|
+
/^\.\.$/ // just ..
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
// Maximum path length (OS limit is usually 4096, but we're conservative)
|
|
25
|
+
const MAX_PATH_LENGTH = 1024;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Validate a tmux session name
|
|
29
|
+
* @param {string} name - Session name to validate
|
|
30
|
+
* @returns {boolean} True if valid
|
|
31
|
+
*/
|
|
32
|
+
function isValidTmuxSessionName(name) {
|
|
33
|
+
if (!name || typeof name !== 'string') {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
// tmux session names can only contain alphanumeric, underscore, hyphen
|
|
37
|
+
return /^[a-zA-Z0-9_-]+$/.test(name);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Validate a session name for safe use
|
|
42
|
+
* @param {string} name - Session name to validate
|
|
43
|
+
* @throws {Error} If validation fails
|
|
44
|
+
* @returns {string} The validated name
|
|
45
|
+
*/
|
|
46
|
+
function validateTmuxSessionName(name) {
|
|
47
|
+
if (!isValidTmuxSessionName(name)) {
|
|
48
|
+
throw new Error('Invalid tmux session name: must contain only alphanumeric characters, underscores, and hyphens');
|
|
49
|
+
}
|
|
50
|
+
return name;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Check if a path contains shell metacharacters
|
|
55
|
+
* @param {string} pathStr - Path to check
|
|
56
|
+
* @returns {boolean} True if contains dangerous characters
|
|
57
|
+
*/
|
|
58
|
+
function containsShellMetacharacters(pathStr) {
|
|
59
|
+
return SHELL_METACHARACTERS.test(pathStr);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Check if a path appears to be a path traversal attempt
|
|
64
|
+
* @param {string} pathStr - Path to check
|
|
65
|
+
* @returns {boolean} True if appears to be traversal attempt
|
|
66
|
+
*/
|
|
67
|
+
function isPathTraversalAttempt(pathStr) {
|
|
68
|
+
return PATH_TRAVERSAL_PATTERNS.some(pattern => pattern.test(pathStr));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Normalize and resolve a path, expanding ~ for home directory
|
|
73
|
+
* @param {string} pathStr - Path to normalize
|
|
74
|
+
* @returns {string} Normalized absolute path
|
|
75
|
+
*/
|
|
76
|
+
function normalizePath(pathStr) {
|
|
77
|
+
if (!pathStr || typeof pathStr !== 'string') {
|
|
78
|
+
return '';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Expand ~ to home directory
|
|
82
|
+
if (pathStr.startsWith('~')) {
|
|
83
|
+
pathStr = path.join(os.homedir(), pathStr.slice(1));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Resolve to absolute path
|
|
87
|
+
return path.resolve(pathStr);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Validate a directory path for safe use
|
|
92
|
+
* @param {string} dirPath - Directory path to validate
|
|
93
|
+
* @param {Object} options - Validation options
|
|
94
|
+
* @param {boolean} options.mustExist - If true, directory must exist (default: true)
|
|
95
|
+
* @param {boolean} options.allowCreate - If true, allows non-existent but valid paths (default: false)
|
|
96
|
+
* @returns {Object} Validation result { valid: boolean, path: string, error: string|null }
|
|
97
|
+
*/
|
|
98
|
+
function validateDirectoryPath(dirPath, options = {}) {
|
|
99
|
+
const { mustExist = true, allowCreate = false } = options;
|
|
100
|
+
|
|
101
|
+
// Check for null/undefined/empty
|
|
102
|
+
if (!dirPath || typeof dirPath !== 'string') {
|
|
103
|
+
return {
|
|
104
|
+
valid: false,
|
|
105
|
+
path: null,
|
|
106
|
+
error: 'Directory path is required'
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Trim whitespace
|
|
111
|
+
const trimmed = dirPath.trim();
|
|
112
|
+
if (!trimmed) {
|
|
113
|
+
return {
|
|
114
|
+
valid: false,
|
|
115
|
+
path: null,
|
|
116
|
+
error: 'Directory path cannot be empty'
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Check path length
|
|
121
|
+
if (trimmed.length > MAX_PATH_LENGTH) {
|
|
122
|
+
return {
|
|
123
|
+
valid: false,
|
|
124
|
+
path: null,
|
|
125
|
+
error: `Path exceeds maximum length of ${MAX_PATH_LENGTH} characters`
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Check for shell metacharacters
|
|
130
|
+
if (containsShellMetacharacters(trimmed)) {
|
|
131
|
+
return {
|
|
132
|
+
valid: false,
|
|
133
|
+
path: null,
|
|
134
|
+
error: 'Path contains invalid characters'
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Check for path traversal
|
|
139
|
+
if (isPathTraversalAttempt(trimmed)) {
|
|
140
|
+
return {
|
|
141
|
+
valid: false,
|
|
142
|
+
path: null,
|
|
143
|
+
error: 'Path contains invalid traversal patterns'
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Normalize the path
|
|
148
|
+
const normalized = normalizePath(trimmed);
|
|
149
|
+
|
|
150
|
+
// Check normalized path for shell metacharacters (after expansion)
|
|
151
|
+
if (containsShellMetacharacters(normalized)) {
|
|
152
|
+
return {
|
|
153
|
+
valid: false,
|
|
154
|
+
path: null,
|
|
155
|
+
error: 'Resolved path contains invalid characters'
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Check if path exists
|
|
160
|
+
if (mustExist && !allowCreate) {
|
|
161
|
+
try {
|
|
162
|
+
const stats = fs.statSync(normalized);
|
|
163
|
+
if (!stats.isDirectory()) {
|
|
164
|
+
return {
|
|
165
|
+
valid: false,
|
|
166
|
+
path: normalized,
|
|
167
|
+
error: 'Path exists but is not a directory'
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
} catch (e) {
|
|
171
|
+
if (e.code === 'ENOENT') {
|
|
172
|
+
return {
|
|
173
|
+
valid: false,
|
|
174
|
+
path: normalized,
|
|
175
|
+
error: 'Directory does not exist'
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
return {
|
|
179
|
+
valid: false,
|
|
180
|
+
path: normalized,
|
|
181
|
+
error: `Cannot access directory: ${e.message}`
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// If allowCreate, check that parent exists
|
|
187
|
+
if (allowCreate && mustExist) {
|
|
188
|
+
const parentDir = path.dirname(normalized);
|
|
189
|
+
try {
|
|
190
|
+
const parentStats = fs.statSync(parentDir);
|
|
191
|
+
if (!parentStats.isDirectory()) {
|
|
192
|
+
return {
|
|
193
|
+
valid: false,
|
|
194
|
+
path: normalized,
|
|
195
|
+
error: 'Parent path is not a directory'
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
} catch (e) {
|
|
199
|
+
return {
|
|
200
|
+
valid: false,
|
|
201
|
+
path: normalized,
|
|
202
|
+
error: 'Parent directory does not exist'
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
valid: true,
|
|
209
|
+
path: normalized,
|
|
210
|
+
error: null
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Validate a file path for safe use
|
|
216
|
+
* @param {string} filePath - File path to validate
|
|
217
|
+
* @param {Object} options - Validation options
|
|
218
|
+
* @param {boolean} options.mustExist - If true, file must exist (default: false)
|
|
219
|
+
* @param {boolean} options.parentMustExist - If true, parent dir must exist (default: true)
|
|
220
|
+
* @returns {Object} Validation result { valid: boolean, path: string, error: string|null }
|
|
221
|
+
*/
|
|
222
|
+
function validateFilePath(filePath, options = {}) {
|
|
223
|
+
const { mustExist = false, parentMustExist = true } = options;
|
|
224
|
+
|
|
225
|
+
// Check for null/undefined/empty
|
|
226
|
+
if (!filePath || typeof filePath !== 'string') {
|
|
227
|
+
return {
|
|
228
|
+
valid: false,
|
|
229
|
+
path: null,
|
|
230
|
+
error: 'File path is required'
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Trim whitespace
|
|
235
|
+
const trimmed = filePath.trim();
|
|
236
|
+
if (!trimmed) {
|
|
237
|
+
return {
|
|
238
|
+
valid: false,
|
|
239
|
+
path: null,
|
|
240
|
+
error: 'File path cannot be empty'
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Check path length
|
|
245
|
+
if (trimmed.length > MAX_PATH_LENGTH) {
|
|
246
|
+
return {
|
|
247
|
+
valid: false,
|
|
248
|
+
path: null,
|
|
249
|
+
error: `Path exceeds maximum length of ${MAX_PATH_LENGTH} characters`
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Check for shell metacharacters
|
|
254
|
+
if (containsShellMetacharacters(trimmed)) {
|
|
255
|
+
return {
|
|
256
|
+
valid: false,
|
|
257
|
+
path: null,
|
|
258
|
+
error: 'Path contains invalid characters'
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Normalize the path
|
|
263
|
+
const normalized = normalizePath(trimmed);
|
|
264
|
+
|
|
265
|
+
// Check if file exists when required
|
|
266
|
+
if (mustExist) {
|
|
267
|
+
try {
|
|
268
|
+
const stats = fs.statSync(normalized);
|
|
269
|
+
if (stats.isDirectory()) {
|
|
270
|
+
return {
|
|
271
|
+
valid: false,
|
|
272
|
+
path: normalized,
|
|
273
|
+
error: 'Path is a directory, not a file'
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
} catch (e) {
|
|
277
|
+
if (e.code === 'ENOENT') {
|
|
278
|
+
return {
|
|
279
|
+
valid: false,
|
|
280
|
+
path: normalized,
|
|
281
|
+
error: 'File does not exist'
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
return {
|
|
285
|
+
valid: false,
|
|
286
|
+
path: normalized,
|
|
287
|
+
error: `Cannot access file: ${e.message}`
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Check parent directory exists
|
|
293
|
+
if (parentMustExist) {
|
|
294
|
+
const parentDir = path.dirname(normalized);
|
|
295
|
+
try {
|
|
296
|
+
const parentStats = fs.statSync(parentDir);
|
|
297
|
+
if (!parentStats.isDirectory()) {
|
|
298
|
+
return {
|
|
299
|
+
valid: false,
|
|
300
|
+
path: normalized,
|
|
301
|
+
error: 'Parent path is not a directory'
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
} catch (e) {
|
|
305
|
+
return {
|
|
306
|
+
valid: false,
|
|
307
|
+
path: normalized,
|
|
308
|
+
error: 'Parent directory does not exist'
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return {
|
|
314
|
+
valid: true,
|
|
315
|
+
path: normalized,
|
|
316
|
+
error: null
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Sanitize a string for use in a filename
|
|
322
|
+
* @param {string} str - String to sanitize
|
|
323
|
+
* @returns {string} Sanitized string
|
|
324
|
+
*/
|
|
325
|
+
function sanitizeForFilename(str) {
|
|
326
|
+
if (!str || typeof str !== 'string') {
|
|
327
|
+
return '';
|
|
328
|
+
}
|
|
329
|
+
// Replace invalid characters with underscore
|
|
330
|
+
return str.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 200);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Check if a path is within a base directory (prevent escaping)
|
|
335
|
+
* @param {string} targetPath - Path to check
|
|
336
|
+
* @param {string} basePath - Base directory that should contain the path
|
|
337
|
+
* @returns {boolean} True if target is within base
|
|
338
|
+
*/
|
|
339
|
+
function isPathWithin(targetPath, basePath) {
|
|
340
|
+
const normalizedTarget = normalizePath(targetPath);
|
|
341
|
+
const normalizedBase = normalizePath(basePath);
|
|
342
|
+
|
|
343
|
+
return normalizedTarget.startsWith(normalizedBase + path.sep) ||
|
|
344
|
+
normalizedTarget === normalizedBase;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
module.exports = {
|
|
348
|
+
// Validation functions
|
|
349
|
+
validateDirectoryPath,
|
|
350
|
+
validateFilePath,
|
|
351
|
+
validateTmuxSessionName,
|
|
352
|
+
isValidTmuxSessionName,
|
|
353
|
+
|
|
354
|
+
// Check functions
|
|
355
|
+
containsShellMetacharacters,
|
|
356
|
+
isPathTraversalAttempt,
|
|
357
|
+
isPathWithin,
|
|
358
|
+
|
|
359
|
+
// Utility functions
|
|
360
|
+
normalizePath,
|
|
361
|
+
sanitizeForFilename,
|
|
362
|
+
|
|
363
|
+
// Constants
|
|
364
|
+
MAX_PATH_LENGTH,
|
|
365
|
+
SHELL_METACHARACTERS
|
|
366
|
+
};
|