agileflow 2.89.3 → 2.90.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 +5 -0
- package/README.md +3 -3
- package/lib/placeholder-registry.js +617 -0
- package/lib/smart-json-file.js +205 -1
- package/lib/table-formatter.js +504 -0
- package/lib/transient-status.js +374 -0
- package/lib/ui-manager.js +612 -0
- package/lib/validate-args.js +213 -0
- package/lib/validate-names.js +143 -0
- package/lib/validate-paths.js +434 -0
- package/lib/validate.js +37 -737
- package/package.json +4 -1
- package/scripts/check-update.js +16 -3
- package/scripts/lib/sessionRegistry.js +682 -0
- package/scripts/session-manager.js +77 -10
- package/scripts/tui/App.js +176 -0
- package/scripts/tui/index.js +75 -0
- package/scripts/tui/lib/crashRecovery.js +302 -0
- package/scripts/tui/lib/eventStream.js +316 -0
- package/scripts/tui/lib/keyboard.js +252 -0
- package/scripts/tui/lib/loopControl.js +371 -0
- package/scripts/tui/panels/OutputPanel.js +278 -0
- package/scripts/tui/panels/SessionPanel.js +178 -0
- package/scripts/tui/panels/TracePanel.js +333 -0
- package/src/core/commands/tui.md +91 -0
- package/tools/cli/commands/config.js +7 -30
- package/tools/cli/commands/doctor.js +18 -38
- package/tools/cli/commands/list.js +47 -35
- package/tools/cli/commands/status.js +13 -37
- package/tools/cli/commands/uninstall.js +9 -38
- package/tools/cli/installers/core/installer.js +13 -0
- package/tools/cli/lib/command-context.js +374 -0
- package/tools/cli/lib/config-manager.js +394 -0
- package/tools/cli/lib/ide-registry.js +186 -0
- package/tools/cli/lib/npm-utils.js +16 -3
- package/tools/cli/lib/self-update.js +148 -0
- package/tools/cli/lib/validation-middleware.js +491 -0
package/lib/validate.js
CHANGED
|
@@ -3,749 +3,47 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Centralized validation patterns and helpers to prevent
|
|
5
5
|
* command injection, path traversal, and invalid input handling.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
const path = require('node:path');
|
|
9
|
-
const fs = require('node:fs');
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Validation patterns for common input types.
|
|
13
|
-
* All patterns use strict whitelisting approach.
|
|
14
|
-
*/
|
|
15
|
-
const PATTERNS = {
|
|
16
|
-
// Git branch: alphanumeric, underscores, hyphens, forward slashes
|
|
17
|
-
// Examples: main, feature/US-0001, session-1, my_branch
|
|
18
|
-
branchName: /^[a-zA-Z0-9][a-zA-Z0-9_/-]*$/,
|
|
19
|
-
|
|
20
|
-
// Story ID: US-0001 to US-99999
|
|
21
|
-
storyId: /^US-\d{4,5}$/,
|
|
22
|
-
|
|
23
|
-
// Epic ID: EP-0001 to EP-99999
|
|
24
|
-
epicId: /^EP-\d{4,5}$/,
|
|
25
|
-
|
|
26
|
-
// Feature name: lowercase with hyphens, starts with letter
|
|
27
|
-
// Examples: damage-control, status-line, archival
|
|
28
|
-
featureName: /^[a-z][a-z0-9-]*$/,
|
|
29
|
-
|
|
30
|
-
// Profile name: alphanumeric with underscores/hyphens, starts with letter
|
|
31
|
-
// Examples: default, my-profile, dev_config
|
|
32
|
-
profileName: /^[a-zA-Z][a-zA-Z0-9_-]*$/,
|
|
33
|
-
|
|
34
|
-
// Command name: alphanumeric with hyphens/colons, starts with letter
|
|
35
|
-
// Examples: babysit, story:list, agileflow:configure
|
|
36
|
-
commandName: /^[a-zA-Z][a-zA-Z0-9:-]*$/,
|
|
37
|
-
|
|
38
|
-
// Session nickname: alphanumeric with hyphens/underscores
|
|
39
|
-
// Examples: auth-work, feature_1, main
|
|
40
|
-
sessionNickname: /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/,
|
|
41
|
-
|
|
42
|
-
// Merge strategy: squash or merge
|
|
43
|
-
// Examples: squash, merge
|
|
44
|
-
mergeStrategy: /^(squash|merge)$/,
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Validate a git branch name.
|
|
49
|
-
* @param {string} name - Branch name to validate
|
|
50
|
-
* @returns {boolean} True if valid
|
|
51
|
-
*/
|
|
52
|
-
function isValidBranchName(name) {
|
|
53
|
-
if (!name || typeof name !== 'string') return false;
|
|
54
|
-
if (name.length > 255) return false; // Git limit
|
|
55
|
-
if (name.startsWith('-')) return false; // Prevent flag injection
|
|
56
|
-
if (name.includes('..')) return false; // Prevent path traversal
|
|
57
|
-
if (name.endsWith('.lock')) return false; // Reserved
|
|
58
|
-
return PATTERNS.branchName.test(name);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Validate a story ID (US-XXXX format).
|
|
63
|
-
* @param {string} id - Story ID to validate
|
|
64
|
-
* @returns {boolean} True if valid
|
|
65
|
-
*/
|
|
66
|
-
function isValidStoryId(id) {
|
|
67
|
-
if (!id || typeof id !== 'string') return false;
|
|
68
|
-
return PATTERNS.storyId.test(id);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Validate an epic ID (EP-XXXX format).
|
|
73
|
-
* @param {string} id - Epic ID to validate
|
|
74
|
-
* @returns {boolean} True if valid
|
|
75
|
-
*/
|
|
76
|
-
function isValidEpicId(id) {
|
|
77
|
-
if (!id || typeof id !== 'string') return false;
|
|
78
|
-
return PATTERNS.epicId.test(id);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Validate a feature name.
|
|
83
|
-
* @param {string} name - Feature name to validate
|
|
84
|
-
* @returns {boolean} True if valid
|
|
85
|
-
*/
|
|
86
|
-
function isValidFeatureName(name) {
|
|
87
|
-
if (!name || typeof name !== 'string') return false;
|
|
88
|
-
if (name.length > 50) return false;
|
|
89
|
-
return PATTERNS.featureName.test(name);
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Validate a profile name.
|
|
94
|
-
* @param {string} name - Profile name to validate
|
|
95
|
-
* @returns {boolean} True if valid
|
|
96
|
-
*/
|
|
97
|
-
function isValidProfileName(name) {
|
|
98
|
-
if (!name || typeof name !== 'string') return false;
|
|
99
|
-
if (name.length > 50) return false;
|
|
100
|
-
return PATTERNS.profileName.test(name);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Validate a command name.
|
|
105
|
-
* @param {string} name - Command name to validate
|
|
106
|
-
* @returns {boolean} True if valid
|
|
107
|
-
*/
|
|
108
|
-
function isValidCommandName(name) {
|
|
109
|
-
if (!name || typeof name !== 'string') return false;
|
|
110
|
-
if (name.length > 100) return false;
|
|
111
|
-
return PATTERNS.commandName.test(name);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* Validate a session nickname.
|
|
116
|
-
* @param {string} name - Nickname to validate
|
|
117
|
-
* @returns {boolean} True if valid
|
|
118
|
-
*/
|
|
119
|
-
function isValidSessionNickname(name) {
|
|
120
|
-
if (!name || typeof name !== 'string') return false;
|
|
121
|
-
if (name.length > 50) return false;
|
|
122
|
-
return PATTERNS.sessionNickname.test(name);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* Validate a merge strategy (squash or merge).
|
|
127
|
-
* @param {string} strategy - Strategy to validate
|
|
128
|
-
* @returns {boolean} True if valid
|
|
129
|
-
*/
|
|
130
|
-
function isValidMergeStrategy(strategy) {
|
|
131
|
-
if (!strategy || typeof strategy !== 'string') return false;
|
|
132
|
-
return PATTERNS.mergeStrategy.test(strategy);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* Validate that a value is a positive integer within bounds.
|
|
137
|
-
* @param {any} val - Value to validate
|
|
138
|
-
* @param {number} min - Minimum allowed value (inclusive)
|
|
139
|
-
* @param {number} max - Maximum allowed value (inclusive)
|
|
140
|
-
* @returns {boolean} True if valid positive integer in range
|
|
141
|
-
*/
|
|
142
|
-
function isPositiveInteger(val, min = 1, max = Number.MAX_SAFE_INTEGER) {
|
|
143
|
-
const num = typeof val === 'string' ? parseInt(val, 10) : val;
|
|
144
|
-
if (!Number.isInteger(num)) return false;
|
|
145
|
-
return num >= min && num <= max;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* Parse and validate an integer with bounds.
|
|
150
|
-
* @param {any} val - Value to parse
|
|
151
|
-
* @param {number} defaultVal - Default if invalid
|
|
152
|
-
* @param {number} min - Minimum allowed value
|
|
153
|
-
* @param {number} max - Maximum allowed value
|
|
154
|
-
* @returns {number} Parsed integer or default
|
|
155
|
-
*/
|
|
156
|
-
function parseIntBounded(val, defaultVal, min = 1, max = Number.MAX_SAFE_INTEGER) {
|
|
157
|
-
const num = typeof val === 'string' ? parseInt(val, 10) : val;
|
|
158
|
-
if (!Number.isInteger(num) || num < min || num > max) {
|
|
159
|
-
return defaultVal;
|
|
160
|
-
}
|
|
161
|
-
return num;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/**
|
|
165
|
-
* Validate an option key against an allowed whitelist.
|
|
166
|
-
* @param {string} key - Option key to validate
|
|
167
|
-
* @param {string[]} allowedKeys - Array of allowed keys
|
|
168
|
-
* @returns {boolean} True if key is in whitelist
|
|
169
|
-
*/
|
|
170
|
-
function isValidOption(key, allowedKeys) {
|
|
171
|
-
if (!key || typeof key !== 'string') return false;
|
|
172
|
-
if (!Array.isArray(allowedKeys)) return false;
|
|
173
|
-
return allowedKeys.includes(key);
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/**
|
|
177
|
-
* Validate and sanitize CLI arguments for known option types.
|
|
178
|
-
* Returns validated args object or null if critical validation fails.
|
|
179
|
-
*
|
|
180
|
-
* @param {string[]} args - Raw process.argv slice
|
|
181
|
-
* @param {Object} schema - Schema defining expected options
|
|
182
|
-
* @returns {{ ok: boolean, data?: Object, error?: string }}
|
|
183
|
-
*
|
|
184
|
-
* @example
|
|
185
|
-
* const schema = {
|
|
186
|
-
* branch: { type: 'branchName', required: true },
|
|
187
|
-
* max: { type: 'positiveInt', min: 1, max: 100, default: 20 },
|
|
188
|
-
* profile: { type: 'profileName', default: 'default' }
|
|
189
|
-
* };
|
|
190
|
-
* const result = validateArgs(args, schema);
|
|
191
|
-
*/
|
|
192
|
-
function validateArgs(args, schema) {
|
|
193
|
-
const result = {};
|
|
194
|
-
const errors = [];
|
|
195
|
-
|
|
196
|
-
// Parse args into key-value pairs
|
|
197
|
-
const parsed = {};
|
|
198
|
-
for (let i = 0; i < args.length; i++) {
|
|
199
|
-
const arg = args[i];
|
|
200
|
-
if (arg.startsWith('--')) {
|
|
201
|
-
const [key, ...valueParts] = arg.slice(2).split('=');
|
|
202
|
-
const value = valueParts.length > 0 ? valueParts.join('=') : args[++i];
|
|
203
|
-
parsed[key] = value;
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
// Validate each schema field
|
|
208
|
-
for (const [field, config] of Object.entries(schema)) {
|
|
209
|
-
const value = parsed[field];
|
|
210
|
-
|
|
211
|
-
// Check required
|
|
212
|
-
if (config.required && (value === undefined || value === null)) {
|
|
213
|
-
errors.push(`Missing required option: --${field}`);
|
|
214
|
-
continue;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
// Use default if not provided
|
|
218
|
-
if (value === undefined || value === null) {
|
|
219
|
-
if (config.default !== undefined) {
|
|
220
|
-
result[field] = config.default;
|
|
221
|
-
}
|
|
222
|
-
continue;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
// Validate by type
|
|
226
|
-
switch (config.type) {
|
|
227
|
-
case 'branchName':
|
|
228
|
-
if (!isValidBranchName(value)) {
|
|
229
|
-
errors.push(`Invalid branch name: ${value}`);
|
|
230
|
-
} else {
|
|
231
|
-
result[field] = value;
|
|
232
|
-
}
|
|
233
|
-
break;
|
|
234
|
-
|
|
235
|
-
case 'storyId':
|
|
236
|
-
if (!isValidStoryId(value)) {
|
|
237
|
-
errors.push(`Invalid story ID: ${value} (expected US-XXXX)`);
|
|
238
|
-
} else {
|
|
239
|
-
result[field] = value;
|
|
240
|
-
}
|
|
241
|
-
break;
|
|
242
|
-
|
|
243
|
-
case 'epicId':
|
|
244
|
-
if (!isValidEpicId(value)) {
|
|
245
|
-
errors.push(`Invalid epic ID: ${value} (expected EP-XXXX)`);
|
|
246
|
-
} else {
|
|
247
|
-
result[field] = value;
|
|
248
|
-
}
|
|
249
|
-
break;
|
|
250
|
-
|
|
251
|
-
case 'featureName':
|
|
252
|
-
if (!isValidFeatureName(value)) {
|
|
253
|
-
errors.push(`Invalid feature name: ${value}`);
|
|
254
|
-
} else {
|
|
255
|
-
result[field] = value;
|
|
256
|
-
}
|
|
257
|
-
break;
|
|
258
|
-
|
|
259
|
-
case 'profileName':
|
|
260
|
-
if (!isValidProfileName(value)) {
|
|
261
|
-
errors.push(`Invalid profile name: ${value}`);
|
|
262
|
-
} else {
|
|
263
|
-
result[field] = value;
|
|
264
|
-
}
|
|
265
|
-
break;
|
|
266
|
-
|
|
267
|
-
case 'commandName':
|
|
268
|
-
if (!isValidCommandName(value)) {
|
|
269
|
-
errors.push(`Invalid command name: ${value}`);
|
|
270
|
-
} else {
|
|
271
|
-
result[field] = value;
|
|
272
|
-
}
|
|
273
|
-
break;
|
|
274
|
-
|
|
275
|
-
case 'sessionNickname':
|
|
276
|
-
if (!isValidSessionNickname(value)) {
|
|
277
|
-
errors.push(`Invalid session nickname: ${value}`);
|
|
278
|
-
} else {
|
|
279
|
-
result[field] = value;
|
|
280
|
-
}
|
|
281
|
-
break;
|
|
282
|
-
|
|
283
|
-
case 'positiveInt': {
|
|
284
|
-
const min = config.min || 1;
|
|
285
|
-
const max = config.max || Number.MAX_SAFE_INTEGER;
|
|
286
|
-
if (!isPositiveInteger(value, min, max)) {
|
|
287
|
-
errors.push(`Invalid integer for ${field}: ${value} (expected ${min}-${max})`);
|
|
288
|
-
result[field] = config.default;
|
|
289
|
-
} else {
|
|
290
|
-
result[field] = parseInt(value, 10);
|
|
291
|
-
}
|
|
292
|
-
break;
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
case 'enum':
|
|
296
|
-
if (!config.values.includes(value)) {
|
|
297
|
-
errors.push(
|
|
298
|
-
`Invalid value for ${field}: ${value} (expected: ${config.values.join(', ')})`
|
|
299
|
-
);
|
|
300
|
-
} else {
|
|
301
|
-
result[field] = value;
|
|
302
|
-
}
|
|
303
|
-
break;
|
|
304
|
-
|
|
305
|
-
case 'boolean':
|
|
306
|
-
result[field] = value === 'true' || value === '1' || value === true;
|
|
307
|
-
break;
|
|
308
|
-
|
|
309
|
-
case 'string':
|
|
310
|
-
// Basic string - just store it (caller should validate further if needed)
|
|
311
|
-
result[field] = String(value);
|
|
312
|
-
break;
|
|
313
|
-
|
|
314
|
-
default:
|
|
315
|
-
result[field] = value;
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
if (errors.length > 0) {
|
|
320
|
-
return { ok: false, error: errors.join('; ') };
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
return { ok: true, data: result };
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
// =============================================================================
|
|
327
|
-
// Path Traversal Protection
|
|
328
|
-
// =============================================================================
|
|
329
|
-
|
|
330
|
-
/**
|
|
331
|
-
* Path validation error with context.
|
|
332
|
-
*/
|
|
333
|
-
class PathValidationError extends Error {
|
|
334
|
-
/**
|
|
335
|
-
* @param {string} message - Error message
|
|
336
|
-
* @param {string} inputPath - The problematic path
|
|
337
|
-
* @param {string} reason - Reason for rejection
|
|
338
|
-
*/
|
|
339
|
-
constructor(message, inputPath, reason) {
|
|
340
|
-
super(message);
|
|
341
|
-
this.name = 'PathValidationError';
|
|
342
|
-
this.inputPath = inputPath;
|
|
343
|
-
this.reason = reason;
|
|
344
|
-
Error.captureStackTrace(this, this.constructor);
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
/**
|
|
349
|
-
* Check the depth of a symlink chain (how many symlinks to follow to reach final target).
|
|
350
|
-
* Returns early if chain exceeds maxDepth to prevent infinite loops from circular symlinks.
|
|
351
|
-
*
|
|
352
|
-
* @param {string} filePath - Starting path to check
|
|
353
|
-
* @param {number} maxDepth - Maximum allowed symlink chain depth
|
|
354
|
-
* @returns {{ ok: boolean, depth: number, error?: string, isCircular?: boolean }}
|
|
355
|
-
*/
|
|
356
|
-
function checkSymlinkChainDepth(filePath, maxDepth) {
|
|
357
|
-
let current = filePath;
|
|
358
|
-
let depth = 0;
|
|
359
|
-
const seen = new Set();
|
|
360
|
-
|
|
361
|
-
// Loop until we find a non-symlink or exceed max depth
|
|
362
|
-
while (true) {
|
|
363
|
-
// Check for circular symlinks
|
|
364
|
-
if (seen.has(current)) {
|
|
365
|
-
return {
|
|
366
|
-
ok: false,
|
|
367
|
-
depth,
|
|
368
|
-
error: `Circular symlink detected at: ${current}`,
|
|
369
|
-
isCircular: true,
|
|
370
|
-
};
|
|
371
|
-
}
|
|
372
|
-
seen.add(current);
|
|
373
|
-
|
|
374
|
-
try {
|
|
375
|
-
const stats = fs.lstatSync(current);
|
|
376
|
-
if (!stats.isSymbolicLink()) {
|
|
377
|
-
// Reached a real file/directory, chain ends
|
|
378
|
-
return { ok: true, depth };
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
// Increment depth before checking limit
|
|
382
|
-
depth++;
|
|
383
|
-
|
|
384
|
-
// Check if we've exceeded max depth
|
|
385
|
-
if (depth > maxDepth) {
|
|
386
|
-
return {
|
|
387
|
-
ok: false,
|
|
388
|
-
depth,
|
|
389
|
-
error: `Symlink chain depth (${depth}) exceeds maximum (${maxDepth})`,
|
|
390
|
-
};
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
// Read symlink target
|
|
394
|
-
const target = fs.readlinkSync(current);
|
|
395
|
-
|
|
396
|
-
// Resolve target path (could be relative or absolute)
|
|
397
|
-
if (path.isAbsolute(target)) {
|
|
398
|
-
current = target;
|
|
399
|
-
} else {
|
|
400
|
-
current = path.resolve(path.dirname(current), target);
|
|
401
|
-
}
|
|
402
|
-
} catch (e) {
|
|
403
|
-
if (e.code === 'ENOENT') {
|
|
404
|
-
// Path doesn't exist, chain ends here
|
|
405
|
-
return { ok: true, depth };
|
|
406
|
-
}
|
|
407
|
-
// Other error (permission denied, etc.)
|
|
408
|
-
return { ok: true, depth };
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
/**
|
|
414
|
-
* Validate that a path is safe and within the allowed base directory.
|
|
415
|
-
* Prevents path traversal attacks by:
|
|
416
|
-
* 1. Resolving the path to absolute form
|
|
417
|
-
* 2. Ensuring it stays within the base directory
|
|
418
|
-
* 3. Rejecting symbolic links (optional)
|
|
419
|
-
* 4. When symlinks allowed, verifying symlink targets stay within base
|
|
420
|
-
* 5. Limiting symlink chain depth to prevent infinite loops
|
|
421
6
|
*
|
|
422
|
-
*
|
|
423
|
-
*
|
|
424
|
-
*
|
|
425
|
-
*
|
|
426
|
-
*
|
|
427
|
-
* @param {number} [options.maxSymlinkDepth=3] - Maximum symlink chain depth (when symlinks allowed)
|
|
428
|
-
* @returns {{ ok: boolean, resolvedPath?: string, realPath?: string, error?: PathValidationError }}
|
|
429
|
-
*
|
|
430
|
-
* @example
|
|
431
|
-
* // Validate a file path within project directory
|
|
432
|
-
* const result = validatePath('./config.yaml', '/home/user/project');
|
|
433
|
-
* if (result.ok) {
|
|
434
|
-
* console.log('Safe path:', result.resolvedPath);
|
|
435
|
-
* }
|
|
436
|
-
*
|
|
437
|
-
* @example
|
|
438
|
-
* // Reject path traversal attempt
|
|
439
|
-
* const result = validatePath('../../../etc/passwd', '/home/user/project');
|
|
440
|
-
* // result.ok === false
|
|
441
|
-
* // result.error.reason === 'path_traversal'
|
|
442
|
-
*
|
|
443
|
-
* @example
|
|
444
|
-
* // Reject deep symlink chains
|
|
445
|
-
* const result = validatePath('link1', baseDir, { allowSymlinks: true, maxSymlinkDepth: 3 });
|
|
446
|
-
* // If link1 -> link2 -> link3 -> link4 -> target, this fails with 'symlink_chain_too_deep'
|
|
7
|
+
* This module re-exports from split validation modules for backward compatibility.
|
|
8
|
+
* For better performance, import directly from:
|
|
9
|
+
* - validate-names.js - Name/ID validation patterns
|
|
10
|
+
* - validate-args.js - CLI argument validation
|
|
11
|
+
* - validate-paths.js - Path traversal protection
|
|
447
12
|
*/
|
|
448
|
-
function validatePath(inputPath, baseDir, options = {}) {
|
|
449
|
-
const { allowSymlinks = false, mustExist = false, maxSymlinkDepth = 3 } = options;
|
|
450
|
-
|
|
451
|
-
// Input validation
|
|
452
|
-
if (!inputPath || typeof inputPath !== 'string') {
|
|
453
|
-
return {
|
|
454
|
-
ok: false,
|
|
455
|
-
error: new PathValidationError(
|
|
456
|
-
'Path is required and must be a string',
|
|
457
|
-
String(inputPath),
|
|
458
|
-
'invalid_input'
|
|
459
|
-
),
|
|
460
|
-
};
|
|
461
|
-
}
|
|
462
13
|
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
if (!path.isAbsolute(baseDir)) {
|
|
476
|
-
return {
|
|
477
|
-
ok: false,
|
|
478
|
-
error: new PathValidationError(
|
|
479
|
-
'Base directory must be an absolute path',
|
|
480
|
-
inputPath,
|
|
481
|
-
'relative_base'
|
|
482
|
-
),
|
|
483
|
-
};
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
// Normalize the base directory
|
|
487
|
-
const normalizedBase = path.resolve(baseDir);
|
|
488
|
-
|
|
489
|
-
// Resolve the input path relative to base directory
|
|
490
|
-
let resolvedPath;
|
|
491
|
-
if (path.isAbsolute(inputPath)) {
|
|
492
|
-
resolvedPath = path.resolve(inputPath);
|
|
493
|
-
} else {
|
|
494
|
-
resolvedPath = path.resolve(normalizedBase, inputPath);
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
// Helper function to check if path is within base
|
|
498
|
-
const checkWithinBase = pathToCheck => {
|
|
499
|
-
const baseWithSep = normalizedBase.endsWith(path.sep)
|
|
500
|
-
? normalizedBase
|
|
501
|
-
: normalizedBase + path.sep;
|
|
502
|
-
return pathToCheck === normalizedBase || pathToCheck.startsWith(baseWithSep);
|
|
503
|
-
};
|
|
504
|
-
|
|
505
|
-
// Check for path traversal: resolved path must start with base directory
|
|
506
|
-
if (!checkWithinBase(resolvedPath)) {
|
|
507
|
-
return {
|
|
508
|
-
ok: false,
|
|
509
|
-
error: new PathValidationError(
|
|
510
|
-
`Path escapes base directory: ${inputPath}`,
|
|
511
|
-
inputPath,
|
|
512
|
-
'path_traversal'
|
|
513
|
-
),
|
|
514
|
-
};
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
// Check if path exists (if required)
|
|
518
|
-
if (mustExist) {
|
|
519
|
-
try {
|
|
520
|
-
fs.accessSync(resolvedPath);
|
|
521
|
-
} catch {
|
|
522
|
-
return {
|
|
523
|
-
ok: false,
|
|
524
|
-
error: new PathValidationError(
|
|
525
|
-
`Path does not exist: ${resolvedPath}`,
|
|
526
|
-
inputPath,
|
|
527
|
-
'not_found'
|
|
528
|
-
),
|
|
529
|
-
};
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
// Check for symbolic links
|
|
534
|
-
if (!allowSymlinks) {
|
|
535
|
-
// Symlinks not allowed - reject if found
|
|
536
|
-
try {
|
|
537
|
-
const stats = fs.lstatSync(resolvedPath);
|
|
538
|
-
if (stats.isSymbolicLink()) {
|
|
539
|
-
return {
|
|
540
|
-
ok: false,
|
|
541
|
-
error: new PathValidationError(
|
|
542
|
-
`Symbolic links are not allowed: ${inputPath}`,
|
|
543
|
-
inputPath,
|
|
544
|
-
'symlink_rejected'
|
|
545
|
-
),
|
|
546
|
-
};
|
|
547
|
-
}
|
|
548
|
-
} catch {
|
|
549
|
-
// Path doesn't exist yet, which is fine if mustExist is false
|
|
550
|
-
// Check parent directories for symlinks
|
|
551
|
-
const parts = path.relative(normalizedBase, resolvedPath).split(path.sep);
|
|
552
|
-
let currentPath = normalizedBase;
|
|
553
|
-
|
|
554
|
-
for (const part of parts) {
|
|
555
|
-
currentPath = path.join(currentPath, part);
|
|
556
|
-
try {
|
|
557
|
-
const stats = fs.lstatSync(currentPath);
|
|
558
|
-
if (stats.isSymbolicLink()) {
|
|
559
|
-
return {
|
|
560
|
-
ok: false,
|
|
561
|
-
error: new PathValidationError(
|
|
562
|
-
`Path contains symbolic link: ${currentPath}`,
|
|
563
|
-
inputPath,
|
|
564
|
-
'symlink_in_path'
|
|
565
|
-
),
|
|
566
|
-
};
|
|
567
|
-
}
|
|
568
|
-
} catch {
|
|
569
|
-
// Part of path doesn't exist, stop checking
|
|
570
|
-
break;
|
|
571
|
-
}
|
|
572
|
-
}
|
|
573
|
-
}
|
|
574
|
-
} else {
|
|
575
|
-
// Symlinks allowed - but we must verify the target stays within base!
|
|
576
|
-
// This prevents symlink-based escape attacks
|
|
577
|
-
try {
|
|
578
|
-
const stats = fs.lstatSync(resolvedPath);
|
|
579
|
-
if (stats.isSymbolicLink()) {
|
|
580
|
-
// Check symlink chain depth to prevent DoS via infinite loops
|
|
581
|
-
const chainResult = checkSymlinkChainDepth(resolvedPath, maxSymlinkDepth);
|
|
582
|
-
if (!chainResult.ok) {
|
|
583
|
-
const reason = chainResult.isCircular ? 'symlink_circular' : 'symlink_chain_too_deep';
|
|
584
|
-
return {
|
|
585
|
-
ok: false,
|
|
586
|
-
error: new PathValidationError(chainResult.error, inputPath, reason),
|
|
587
|
-
};
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
// Resolve the symlink target to its real path
|
|
591
|
-
const realPath = fs.realpathSync(resolvedPath);
|
|
592
|
-
|
|
593
|
-
// Verify the real path is also within base directory
|
|
594
|
-
if (!checkWithinBase(realPath)) {
|
|
595
|
-
return {
|
|
596
|
-
ok: false,
|
|
597
|
-
error: new PathValidationError(
|
|
598
|
-
`Symlink target escapes base directory: ${inputPath} -> ${realPath}`,
|
|
599
|
-
inputPath,
|
|
600
|
-
'symlink_escape'
|
|
601
|
-
),
|
|
602
|
-
};
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
// Return both the resolved path and the real path
|
|
606
|
-
return {
|
|
607
|
-
ok: true,
|
|
608
|
-
resolvedPath,
|
|
609
|
-
realPath,
|
|
610
|
-
};
|
|
611
|
-
}
|
|
612
|
-
} catch {
|
|
613
|
-
// Path doesn't exist - that's okay for non-mustExist scenarios
|
|
614
|
-
// Also check parent directories for symlinks that might escape
|
|
615
|
-
const parts = path.relative(normalizedBase, resolvedPath).split(path.sep);
|
|
616
|
-
let currentPath = normalizedBase;
|
|
617
|
-
|
|
618
|
-
for (const part of parts) {
|
|
619
|
-
currentPath = path.join(currentPath, part);
|
|
620
|
-
try {
|
|
621
|
-
const stats = fs.lstatSync(currentPath);
|
|
622
|
-
if (stats.isSymbolicLink()) {
|
|
623
|
-
// Check symlink chain depth
|
|
624
|
-
const chainResult = checkSymlinkChainDepth(currentPath, maxSymlinkDepth);
|
|
625
|
-
if (!chainResult.ok) {
|
|
626
|
-
const reason = chainResult.isCircular ? 'symlink_circular' : 'symlink_chain_too_deep';
|
|
627
|
-
return {
|
|
628
|
-
ok: false,
|
|
629
|
-
error: new PathValidationError(chainResult.error, inputPath, reason),
|
|
630
|
-
};
|
|
631
|
-
}
|
|
632
|
-
|
|
633
|
-
// Resolve this symlink and check its target
|
|
634
|
-
const realPath = fs.realpathSync(currentPath);
|
|
635
|
-
if (!checkWithinBase(realPath)) {
|
|
636
|
-
return {
|
|
637
|
-
ok: false,
|
|
638
|
-
error: new PathValidationError(
|
|
639
|
-
`Path contains symlink escaping base: ${currentPath} -> ${realPath}`,
|
|
640
|
-
inputPath,
|
|
641
|
-
'symlink_escape'
|
|
642
|
-
),
|
|
643
|
-
};
|
|
644
|
-
}
|
|
645
|
-
}
|
|
646
|
-
} catch {
|
|
647
|
-
// Part of path doesn't exist, stop checking
|
|
648
|
-
break;
|
|
649
|
-
}
|
|
650
|
-
}
|
|
651
|
-
}
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
return {
|
|
655
|
-
ok: true,
|
|
656
|
-
resolvedPath,
|
|
657
|
-
};
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
/**
|
|
661
|
-
* Synchronous version that throws on invalid paths.
|
|
662
|
-
* Use when you want exceptions rather than result objects.
|
|
663
|
-
*
|
|
664
|
-
* @param {string} inputPath - The path to validate
|
|
665
|
-
* @param {string} baseDir - The allowed base directory
|
|
666
|
-
* @param {Object} options - Validation options
|
|
667
|
-
* @returns {string} The validated absolute path
|
|
668
|
-
* @throws {PathValidationError} If path is invalid
|
|
669
|
-
*/
|
|
670
|
-
function validatePathSync(inputPath, baseDir, options = {}) {
|
|
671
|
-
const result = validatePath(inputPath, baseDir, options);
|
|
672
|
-
if (!result.ok) {
|
|
673
|
-
throw result.error;
|
|
674
|
-
}
|
|
675
|
-
return result.resolvedPath;
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
/**
|
|
679
|
-
* Check if a path contains dangerous patterns without resolving.
|
|
680
|
-
* Useful for quick pre-validation before expensive operations.
|
|
681
|
-
*
|
|
682
|
-
* @param {string} inputPath - The path to check
|
|
683
|
-
* @returns {{ safe: boolean, reason?: string }}
|
|
684
|
-
*/
|
|
685
|
-
function hasUnsafePathPatterns(inputPath) {
|
|
686
|
-
if (!inputPath || typeof inputPath !== 'string') {
|
|
687
|
-
return { safe: false, reason: 'invalid_input' };
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
// Check for null bytes (can bypass security in some systems)
|
|
691
|
-
if (inputPath.includes('\0')) {
|
|
692
|
-
return { safe: false, reason: 'null_byte' };
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
// Check for obvious traversal patterns
|
|
696
|
-
if (inputPath.includes('..')) {
|
|
697
|
-
return { safe: false, reason: 'dot_dot_sequence' };
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
// Check for absolute paths on Unix when expecting relative
|
|
701
|
-
if (inputPath.startsWith('/') && !path.isAbsolute(inputPath)) {
|
|
702
|
-
return { safe: false, reason: 'unexpected_absolute' };
|
|
703
|
-
}
|
|
704
|
-
|
|
705
|
-
// Check for Windows-style absolute paths
|
|
706
|
-
if (/^[a-zA-Z]:/.test(inputPath)) {
|
|
707
|
-
return { safe: false, reason: 'windows_absolute' };
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
return { safe: true };
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
/**
|
|
714
|
-
* Sanitize a filename by removing dangerous characters.
|
|
715
|
-
* Does NOT validate the full path - use with validatePath().
|
|
716
|
-
*
|
|
717
|
-
* @param {string} filename - The filename to sanitize
|
|
718
|
-
* @param {Object} options - Sanitization options
|
|
719
|
-
* @param {string} [options.replacement='_'] - Character to replace with
|
|
720
|
-
* @param {number} [options.maxLength=255] - Maximum filename length
|
|
721
|
-
* @returns {string} Sanitized filename
|
|
722
|
-
*/
|
|
723
|
-
function sanitizeFilename(filename, options = {}) {
|
|
724
|
-
const { replacement = '_', maxLength = 255 } = options;
|
|
725
|
-
|
|
726
|
-
if (!filename || typeof filename !== 'string') {
|
|
727
|
-
return '';
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
// Remove or replace dangerous characters
|
|
731
|
-
let sanitized = filename
|
|
732
|
-
.replace(/[<>:"/\\|?*\x00-\x1f]/g, replacement) // Control chars and reserved
|
|
733
|
-
.replace(/\.{2,}/g, replacement) // Multiple dots
|
|
734
|
-
.replace(/^\.+/, replacement) // Leading dots
|
|
735
|
-
.replace(/^-+/, replacement); // Leading dashes (prevent flag injection)
|
|
14
|
+
// Re-export name validators
|
|
15
|
+
const {
|
|
16
|
+
PATTERNS,
|
|
17
|
+
isValidBranchName,
|
|
18
|
+
isValidStoryId,
|
|
19
|
+
isValidEpicId,
|
|
20
|
+
isValidFeatureName,
|
|
21
|
+
isValidProfileName,
|
|
22
|
+
isValidCommandName,
|
|
23
|
+
isValidSessionNickname,
|
|
24
|
+
isValidMergeStrategy,
|
|
25
|
+
} = require('./validate-names');
|
|
736
26
|
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
27
|
+
// Re-export argument validators
|
|
28
|
+
const {
|
|
29
|
+
isPositiveInteger,
|
|
30
|
+
parseIntBounded,
|
|
31
|
+
isValidOption,
|
|
32
|
+
validateArgs,
|
|
33
|
+
} = require('./validate-args');
|
|
743
34
|
|
|
744
|
-
|
|
745
|
-
|
|
35
|
+
// Re-export path validators
|
|
36
|
+
const {
|
|
37
|
+
PathValidationError,
|
|
38
|
+
checkSymlinkChainDepth,
|
|
39
|
+
validatePath,
|
|
40
|
+
validatePathSync,
|
|
41
|
+
hasUnsafePathPatterns,
|
|
42
|
+
sanitizeFilename,
|
|
43
|
+
} = require('./validate-paths');
|
|
746
44
|
|
|
747
45
|
module.exports = {
|
|
748
|
-
// Patterns and basic validators
|
|
46
|
+
// Patterns and basic validators (from validate-names.js)
|
|
749
47
|
PATTERNS,
|
|
750
48
|
isValidBranchName,
|
|
751
49
|
isValidStoryId,
|
|
@@ -755,12 +53,14 @@ module.exports = {
|
|
|
755
53
|
isValidCommandName,
|
|
756
54
|
isValidSessionNickname,
|
|
757
55
|
isValidMergeStrategy,
|
|
56
|
+
|
|
57
|
+
// Argument validators (from validate-args.js)
|
|
758
58
|
isPositiveInteger,
|
|
759
59
|
parseIntBounded,
|
|
760
60
|
isValidOption,
|
|
761
61
|
validateArgs,
|
|
762
62
|
|
|
763
|
-
// Path traversal protection
|
|
63
|
+
// Path traversal protection (from validate-paths.js)
|
|
764
64
|
PathValidationError,
|
|
765
65
|
validatePath,
|
|
766
66
|
validatePathSync,
|