agileflow 2.89.3 → 2.90.1

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.
Files changed (37) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/lib/placeholder-registry.js +617 -0
  3. package/lib/smart-json-file.js +228 -1
  4. package/lib/table-formatter.js +519 -0
  5. package/lib/transient-status.js +374 -0
  6. package/lib/ui-manager.js +612 -0
  7. package/lib/validate-args.js +213 -0
  8. package/lib/validate-names.js +143 -0
  9. package/lib/validate-paths.js +434 -0
  10. package/lib/validate.js +37 -737
  11. package/package.json +3 -1
  12. package/scripts/check-update.js +17 -3
  13. package/scripts/lib/sessionRegistry.js +678 -0
  14. package/scripts/session-manager.js +77 -10
  15. package/scripts/tui/App.js +151 -0
  16. package/scripts/tui/index.js +31 -0
  17. package/scripts/tui/lib/crashRecovery.js +304 -0
  18. package/scripts/tui/lib/eventStream.js +309 -0
  19. package/scripts/tui/lib/keyboard.js +261 -0
  20. package/scripts/tui/lib/loopControl.js +371 -0
  21. package/scripts/tui/panels/OutputPanel.js +242 -0
  22. package/scripts/tui/panels/SessionPanel.js +170 -0
  23. package/scripts/tui/panels/TracePanel.js +298 -0
  24. package/scripts/tui/simple-tui.js +390 -0
  25. package/tools/cli/commands/config.js +7 -31
  26. package/tools/cli/commands/doctor.js +28 -39
  27. package/tools/cli/commands/list.js +47 -35
  28. package/tools/cli/commands/status.js +20 -38
  29. package/tools/cli/commands/tui.js +59 -0
  30. package/tools/cli/commands/uninstall.js +12 -39
  31. package/tools/cli/installers/core/installer.js +13 -0
  32. package/tools/cli/lib/command-context.js +382 -0
  33. package/tools/cli/lib/config-manager.js +394 -0
  34. package/tools/cli/lib/ide-registry.js +186 -0
  35. package/tools/cli/lib/npm-utils.js +17 -3
  36. package/tools/cli/lib/self-update.js +148 -0
  37. 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
- * @param {string} inputPath - The path to validate (can be relative or absolute)
423
- * @param {string} baseDir - The allowed base directory (must be absolute)
424
- * @param {Object} options - Validation options
425
- * @param {boolean} [options.allowSymlinks=false] - Allow symbolic links
426
- * @param {boolean} [options.mustExist=false] - Path must exist on filesystem
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
- if (!baseDir || typeof baseDir !== 'string') {
464
- return {
465
- ok: false,
466
- error: new PathValidationError(
467
- 'Base directory is required and must be a string',
468
- inputPath,
469
- 'invalid_base'
470
- ),
471
- };
472
- }
473
-
474
- // Base directory must be absolute
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
- // Truncate if too long
738
- if (sanitized.length > maxLength) {
739
- const ext = path.extname(sanitized);
740
- const base = path.basename(sanitized, ext);
741
- sanitized = base.slice(0, maxLength - ext.length) + ext;
742
- }
27
+ // Re-export argument validators
28
+ const {
29
+ isPositiveInteger,
30
+ parseIntBounded,
31
+ isValidOption,
32
+ validateArgs,
33
+ } = require('./validate-args');
743
34
 
744
- return sanitized;
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,