agileflow 2.88.0 → 2.89.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/lib/validate.js CHANGED
@@ -2,9 +2,12 @@
2
2
  * AgileFlow CLI - Input Validation Utilities
3
3
  *
4
4
  * Centralized validation patterns and helpers to prevent
5
- * command injection and invalid input handling.
5
+ * command injection, path traversal, and invalid input handling.
6
6
  */
7
7
 
8
+ const path = require('node:path');
9
+ const fs = require('node:fs');
10
+
8
11
  /**
9
12
  * Validation patterns for common input types.
10
13
  * All patterns use strict whitelisting approach.
@@ -320,7 +323,277 @@ function validateArgs(args, schema) {
320
323
  return { ok: true, data: result };
321
324
  }
322
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
+ * Validate that a path is safe and within the allowed base directory.
350
+ * Prevents path traversal attacks by:
351
+ * 1. Resolving the path to absolute form
352
+ * 2. Ensuring it stays within the base directory
353
+ * 3. Rejecting symbolic links (optional)
354
+ *
355
+ * @param {string} inputPath - The path to validate (can be relative or absolute)
356
+ * @param {string} baseDir - The allowed base directory (must be absolute)
357
+ * @param {Object} options - Validation options
358
+ * @param {boolean} [options.allowSymlinks=false] - Allow symbolic links
359
+ * @param {boolean} [options.mustExist=false] - Path must exist on filesystem
360
+ * @returns {{ ok: boolean, resolvedPath?: string, error?: PathValidationError }}
361
+ *
362
+ * @example
363
+ * // Validate a file path within project directory
364
+ * const result = validatePath('./config.yaml', '/home/user/project');
365
+ * if (result.ok) {
366
+ * console.log('Safe path:', result.resolvedPath);
367
+ * }
368
+ *
369
+ * @example
370
+ * // Reject path traversal attempt
371
+ * const result = validatePath('../../../etc/passwd', '/home/user/project');
372
+ * // result.ok === false
373
+ * // result.error.reason === 'path_traversal'
374
+ */
375
+ function validatePath(inputPath, baseDir, options = {}) {
376
+ const { allowSymlinks = false, mustExist = false } = options;
377
+
378
+ // Input validation
379
+ if (!inputPath || typeof inputPath !== 'string') {
380
+ return {
381
+ ok: false,
382
+ error: new PathValidationError(
383
+ 'Path is required and must be a string',
384
+ String(inputPath),
385
+ 'invalid_input'
386
+ ),
387
+ };
388
+ }
389
+
390
+ if (!baseDir || typeof baseDir !== 'string') {
391
+ return {
392
+ ok: false,
393
+ error: new PathValidationError(
394
+ 'Base directory is required and must be a string',
395
+ inputPath,
396
+ 'invalid_base'
397
+ ),
398
+ };
399
+ }
400
+
401
+ // Base directory must be absolute
402
+ if (!path.isAbsolute(baseDir)) {
403
+ return {
404
+ ok: false,
405
+ error: new PathValidationError(
406
+ 'Base directory must be an absolute path',
407
+ inputPath,
408
+ 'relative_base'
409
+ ),
410
+ };
411
+ }
412
+
413
+ // Normalize the base directory
414
+ const normalizedBase = path.resolve(baseDir);
415
+
416
+ // Resolve the input path relative to base directory
417
+ let resolvedPath;
418
+ if (path.isAbsolute(inputPath)) {
419
+ resolvedPath = path.resolve(inputPath);
420
+ } else {
421
+ resolvedPath = path.resolve(normalizedBase, inputPath);
422
+ }
423
+
424
+ // Check for path traversal: resolved path must start with base directory
425
+ // Add trailing separator to prevent prefix attacks (e.g., /home/user vs /home/user2)
426
+ const baseWithSep = normalizedBase.endsWith(path.sep)
427
+ ? normalizedBase
428
+ : normalizedBase + path.sep;
429
+
430
+ const isWithinBase =
431
+ resolvedPath === normalizedBase || resolvedPath.startsWith(baseWithSep);
432
+
433
+ if (!isWithinBase) {
434
+ return {
435
+ ok: false,
436
+ error: new PathValidationError(
437
+ `Path escapes base directory: ${inputPath}`,
438
+ inputPath,
439
+ 'path_traversal'
440
+ ),
441
+ };
442
+ }
443
+
444
+ // Check if path exists (if required)
445
+ if (mustExist) {
446
+ try {
447
+ fs.accessSync(resolvedPath);
448
+ } catch {
449
+ return {
450
+ ok: false,
451
+ error: new PathValidationError(
452
+ `Path does not exist: ${resolvedPath}`,
453
+ inputPath,
454
+ 'not_found'
455
+ ),
456
+ };
457
+ }
458
+ }
459
+
460
+ // Check for symbolic links (if not allowed)
461
+ if (!allowSymlinks) {
462
+ try {
463
+ const stats = fs.lstatSync(resolvedPath);
464
+ if (stats.isSymbolicLink()) {
465
+ return {
466
+ ok: false,
467
+ error: new PathValidationError(
468
+ `Symbolic links are not allowed: ${inputPath}`,
469
+ inputPath,
470
+ 'symlink_rejected'
471
+ ),
472
+ };
473
+ }
474
+ } catch {
475
+ // Path doesn't exist yet, which is fine if mustExist is false
476
+ // Check parent directories for symlinks
477
+ const parts = path.relative(normalizedBase, resolvedPath).split(path.sep);
478
+ let currentPath = normalizedBase;
479
+
480
+ for (const part of parts) {
481
+ currentPath = path.join(currentPath, part);
482
+ try {
483
+ const stats = fs.lstatSync(currentPath);
484
+ if (stats.isSymbolicLink()) {
485
+ return {
486
+ ok: false,
487
+ error: new PathValidationError(
488
+ `Path contains symbolic link: ${currentPath}`,
489
+ inputPath,
490
+ 'symlink_in_path'
491
+ ),
492
+ };
493
+ }
494
+ } catch {
495
+ // Part of path doesn't exist, stop checking
496
+ break;
497
+ }
498
+ }
499
+ }
500
+ }
501
+
502
+ return {
503
+ ok: true,
504
+ resolvedPath,
505
+ };
506
+ }
507
+
508
+ /**
509
+ * Synchronous version that throws on invalid paths.
510
+ * Use when you want exceptions rather than result objects.
511
+ *
512
+ * @param {string} inputPath - The path to validate
513
+ * @param {string} baseDir - The allowed base directory
514
+ * @param {Object} options - Validation options
515
+ * @returns {string} The validated absolute path
516
+ * @throws {PathValidationError} If path is invalid
517
+ */
518
+ function validatePathSync(inputPath, baseDir, options = {}) {
519
+ const result = validatePath(inputPath, baseDir, options);
520
+ if (!result.ok) {
521
+ throw result.error;
522
+ }
523
+ return result.resolvedPath;
524
+ }
525
+
526
+ /**
527
+ * Check if a path contains dangerous patterns without resolving.
528
+ * Useful for quick pre-validation before expensive operations.
529
+ *
530
+ * @param {string} inputPath - The path to check
531
+ * @returns {{ safe: boolean, reason?: string }}
532
+ */
533
+ function hasUnsafePathPatterns(inputPath) {
534
+ if (!inputPath || typeof inputPath !== 'string') {
535
+ return { safe: false, reason: 'invalid_input' };
536
+ }
537
+
538
+ // Check for null bytes (can bypass security in some systems)
539
+ if (inputPath.includes('\0')) {
540
+ return { safe: false, reason: 'null_byte' };
541
+ }
542
+
543
+ // Check for obvious traversal patterns
544
+ if (inputPath.includes('..')) {
545
+ return { safe: false, reason: 'dot_dot_sequence' };
546
+ }
547
+
548
+ // Check for absolute paths on Unix when expecting relative
549
+ if (inputPath.startsWith('/') && !path.isAbsolute(inputPath)) {
550
+ return { safe: false, reason: 'unexpected_absolute' };
551
+ }
552
+
553
+ // Check for Windows-style absolute paths
554
+ if (/^[a-zA-Z]:/.test(inputPath)) {
555
+ return { safe: false, reason: 'windows_absolute' };
556
+ }
557
+
558
+ return { safe: true };
559
+ }
560
+
561
+ /**
562
+ * Sanitize a filename by removing dangerous characters.
563
+ * Does NOT validate the full path - use with validatePath().
564
+ *
565
+ * @param {string} filename - The filename to sanitize
566
+ * @param {Object} options - Sanitization options
567
+ * @param {string} [options.replacement='_'] - Character to replace with
568
+ * @param {number} [options.maxLength=255] - Maximum filename length
569
+ * @returns {string} Sanitized filename
570
+ */
571
+ function sanitizeFilename(filename, options = {}) {
572
+ const { replacement = '_', maxLength = 255 } = options;
573
+
574
+ if (!filename || typeof filename !== 'string') {
575
+ return '';
576
+ }
577
+
578
+ // Remove or replace dangerous characters
579
+ let sanitized = filename
580
+ .replace(/[<>:"/\\|?*\x00-\x1f]/g, replacement) // Control chars and reserved
581
+ .replace(/\.{2,}/g, replacement) // Multiple dots
582
+ .replace(/^\.+/, replacement) // Leading dots
583
+ .replace(/^-+/, replacement); // Leading dashes (prevent flag injection)
584
+
585
+ // Truncate if too long
586
+ if (sanitized.length > maxLength) {
587
+ const ext = path.extname(sanitized);
588
+ const base = path.basename(sanitized, ext);
589
+ sanitized = base.slice(0, maxLength - ext.length) + ext;
590
+ }
591
+
592
+ return sanitized;
593
+ }
594
+
323
595
  module.exports = {
596
+ // Patterns and basic validators
324
597
  PATTERNS,
325
598
  isValidBranchName,
326
599
  isValidStoryId,
@@ -334,4 +607,11 @@ module.exports = {
334
607
  parseIntBounded,
335
608
  isValidOption,
336
609
  validateArgs,
610
+
611
+ // Path traversal protection
612
+ PathValidationError,
613
+ validatePath,
614
+ validatePathSync,
615
+ hasUnsafePathPatterns,
616
+ sanitizeFilename,
337
617
  };
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Centralized YAML parsing utilities with security guarantees.
3
+ *
4
+ * Security Note (js-yaml v4.x):
5
+ * - yaml.load() is SAFE by default in v4+ (unlike v3.x)
6
+ * - Dangerous JavaScript types (functions, regexps) are NOT supported
7
+ * - Only JSON-compatible types are parsed
8
+ * - See: https://github.com/nodeca/js-yaml/blob/master/migrate_v3_to_v4.md
9
+ *
10
+ * This wrapper provides:
11
+ * - Explicit security documentation
12
+ * - Input validation
13
+ * - Consistent error handling
14
+ * - Future-proofing for library changes
15
+ */
16
+
17
+ const yaml = require('js-yaml');
18
+
19
+ /**
20
+ * Safely parse YAML content. Uses js-yaml's DEFAULT_SCHEMA which is
21
+ * safe by default in v4+ (does not execute arbitrary JavaScript).
22
+ *
23
+ * @param {string} content - YAML string to parse
24
+ * @param {Object} options - Optional yaml.load options
25
+ * @returns {any} Parsed YAML content (object, array, or primitive)
26
+ * @throws {yaml.YAMLException} If YAML is malformed
27
+ */
28
+ function safeLoad(content, options = {}) {
29
+ if (typeof content !== 'string') {
30
+ throw new TypeError('YAML content must be a string');
31
+ }
32
+
33
+ // In js-yaml v4+, load() uses DEFAULT_SCHEMA which is safe.
34
+ // Explicitly pass schema to make the security guarantee clear
35
+ // and protect against future library changes.
36
+ return yaml.load(content, {
37
+ schema: yaml.DEFAULT_SCHEMA,
38
+ ...options,
39
+ });
40
+ }
41
+
42
+ /**
43
+ * Safely parse all YAML documents in a multi-document file.
44
+ *
45
+ * @param {string} content - YAML string containing one or more documents
46
+ * @param {Object} options - Optional yaml.loadAll options
47
+ * @returns {any[]} Array of parsed YAML documents
48
+ * @throws {yaml.YAMLException} If YAML is malformed
49
+ */
50
+ function safeLoadAll(content, options = {}) {
51
+ if (typeof content !== 'string') {
52
+ throw new TypeError('YAML content must be a string');
53
+ }
54
+
55
+ const documents = [];
56
+ yaml.loadAll(
57
+ content,
58
+ (doc) => documents.push(doc),
59
+ { schema: yaml.DEFAULT_SCHEMA, ...options }
60
+ );
61
+ return documents;
62
+ }
63
+
64
+ /**
65
+ * Safely serialize data to YAML string.
66
+ *
67
+ * @param {any} data - Data to serialize
68
+ * @param {Object} options - Optional yaml.dump options
69
+ * @returns {string} YAML string representation
70
+ */
71
+ function safeDump(data, options = {}) {
72
+ return yaml.dump(data, {
73
+ schema: yaml.DEFAULT_SCHEMA,
74
+ ...options,
75
+ });
76
+ }
77
+
78
+ /**
79
+ * Test if js-yaml is configured securely (no JavaScript type support).
80
+ * This function is used in security tests to verify the library version
81
+ * and configuration.
82
+ *
83
+ * @returns {boolean} True if the configuration is secure
84
+ */
85
+ function isSecureConfiguration() {
86
+ // Attempt to parse YAML with JavaScript function syntax
87
+ // This should NOT execute any code in v4+
88
+ const maliciousYaml = `
89
+ test: !!js/function 'function() { return "pwned"; }'
90
+ regex: !!js/regexp /test/i
91
+ undef: !!js/undefined ''
92
+ `;
93
+
94
+ try {
95
+ const result = safeLoad(maliciousYaml);
96
+ // If we get here without executing code, and the values are
97
+ // either strings or undefined (not actual functions), we're safe
98
+ if (typeof result.test === 'function') {
99
+ return false; // Unsafe: function was instantiated
100
+ }
101
+ if (result.regex instanceof RegExp) {
102
+ return false; // Unsafe: regex was instantiated
103
+ }
104
+ // Values should be undefined or error out due to unknown tags
105
+ return true;
106
+ } catch (e) {
107
+ // YAMLException for unknown tags is the expected safe behavior
108
+ if (e.name === 'YAMLException') {
109
+ return true;
110
+ }
111
+ throw e;
112
+ }
113
+ }
114
+
115
+ module.exports = {
116
+ safeLoad,
117
+ safeLoadAll,
118
+ safeDump,
119
+ isSecureConfiguration,
120
+ // Re-export yaml module for edge cases that need direct access
121
+ yaml,
122
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agileflow",
3
- "version": "2.88.0",
3
+ "version": "2.89.0",
4
4
  "description": "AI-driven agile development system for Claude Code, Cursor, Windsurf, and more",
5
5
  "keywords": [
6
6
  "agile",
@@ -18,6 +18,7 @@ const { execSync, spawnSync } = require('child_process');
18
18
  // Shared utilities
19
19
  const { c, box } = require('../lib/colors');
20
20
  const { getProjectRoot } = require('../lib/paths');
21
+ const { readJSONCached, readFileCached } = require('../lib/file-cache');
21
22
 
22
23
  // Session manager path (relative to script location)
23
24
  const SESSION_MANAGER_PATH = path.join(__dirname, 'session-manager.js');
@@ -47,44 +48,29 @@ try {
47
48
  }
48
49
 
49
50
  /**
50
- * PERFORMANCE OPTIMIZATION: Load all project files once into cache
51
- * This eliminates duplicate file reads across multiple functions.
52
- * Estimated savings: 40-80ms
51
+ * PERFORMANCE OPTIMIZATION: Load all project files using LRU cache
52
+ * Uses file-cache module for automatic caching with 30s TTL.
53
+ * Files are cached across script invocations within TTL window.
54
+ * Estimated savings: 60-120ms on cache hits
53
55
  */
54
56
  function loadProjectFiles(rootDir) {
55
- const cache = {
56
- status: null,
57
- metadata: null,
58
- settings: null,
59
- sessionState: null,
60
- configYaml: null,
61
- cliPackage: null,
62
- };
63
-
64
57
  const paths = {
65
- status: 'docs/09-agents/status.json',
66
- metadata: 'docs/00-meta/agileflow-metadata.json',
67
- settings: '.claude/settings.json',
68
- sessionState: 'docs/09-agents/session-state.json',
69
- configYaml: '.agileflow/config.yaml',
70
- cliPackage: 'packages/cli/package.json',
58
+ status: path.join(rootDir, 'docs', '09-agents', 'status.json'),
59
+ metadata: path.join(rootDir, 'docs', '00-meta', 'agileflow-metadata.json'),
60
+ settings: path.join(rootDir, '.claude', 'settings.json'),
61
+ sessionState: path.join(rootDir, 'docs', '09-agents', 'session-state.json'),
62
+ configYaml: path.join(rootDir, '.agileflow', 'config.yaml'),
63
+ cliPackage: path.join(rootDir, 'packages', 'cli', 'package.json'),
71
64
  };
72
65
 
73
- for (const [key, relPath] of Object.entries(paths)) {
74
- const fullPath = path.join(rootDir, relPath);
75
- try {
76
- if (!fs.existsSync(fullPath)) continue;
77
- if (key === 'configYaml') {
78
- cache[key] = fs.readFileSync(fullPath, 'utf8');
79
- } else {
80
- cache[key] = JSON.parse(fs.readFileSync(fullPath, 'utf8'));
81
- }
82
- } catch (e) {
83
- // Silently ignore - file not available or invalid
84
- }
85
- }
86
-
87
- return cache;
66
+ return {
67
+ status: readJSONCached(paths.status),
68
+ metadata: readJSONCached(paths.metadata),
69
+ settings: readJSONCached(paths.settings),
70
+ sessionState: readJSONCached(paths.sessionState),
71
+ configYaml: readFileCached(paths.configYaml),
72
+ cliPackage: readJSONCached(paths.cliPackage),
73
+ };
88
74
  }
89
75
 
90
76
  /**
@@ -20,6 +20,7 @@ const path = require('path');
20
20
  const { execSync } = require('child_process');
21
21
  const { c: C, box } = require('../lib/colors');
22
22
  const { isValidCommandName } = require('../lib/validate');
23
+ const { readJSONCached, readFileCached } = require('../lib/file-cache');
23
24
 
24
25
  // Claude Code's Bash tool truncates around 30K chars, but ANSI codes and
25
26
  // box-drawing characters (╭╮╰╯─│) are multi-byte UTF-8, so we need buffer.
@@ -131,11 +132,9 @@ function safeRead(filePath) {
131
132
  }
132
133
 
133
134
  function safeReadJSON(filePath) {
134
- try {
135
- return JSON.parse(fs.readFileSync(filePath, 'utf8'));
136
- } catch {
137
- return null;
138
- }
135
+ // Use cached read for common JSON files
136
+ const absPath = path.resolve(filePath);
137
+ return readJSONCached(absPath);
139
138
  }
140
139
 
141
140
  function safeLs(dirPath) {
@@ -11,6 +11,7 @@ const ora = require('ora');
11
11
  const yaml = require('js-yaml');
12
12
  const { injectContent } = require('../../lib/content-injector');
13
13
  const { sha256Hex, toPosixPath, safeTimestampForPath } = require('../../lib/utils');
14
+ const { validatePath, PathValidationError } = require('../../../../lib/validate');
14
15
 
15
16
  const TEXT_EXTENSIONS = new Set(['.md', '.yaml', '.yml', '.txt', '.json']);
16
17
 
@@ -191,6 +192,22 @@ class Installer {
191
192
  }
192
193
  }
193
194
 
195
+ /**
196
+ * Validate that a path is within the allowed installation directory.
197
+ * Prevents path traversal attacks when writing files.
198
+ *
199
+ * @param {string} filePath - Path to validate
200
+ * @param {string} baseDir - Allowed base directory
201
+ * @throws {PathValidationError} If path escapes base directory
202
+ */
203
+ validateInstallPath(filePath, baseDir) {
204
+ const result = validatePath(filePath, baseDir, { allowSymlinks: false });
205
+ if (!result.ok) {
206
+ throw result.error;
207
+ }
208
+ return result.resolvedPath;
209
+ }
210
+
194
211
  /**
195
212
  * Copy content from source to destination with placeholder replacement
196
213
  * @param {string} source - Source directory
@@ -201,10 +218,16 @@ class Installer {
201
218
  async copyContent(source, dest, agileflowFolder, policy = null) {
202
219
  const entries = await fs.readdir(source, { withFileTypes: true });
203
220
 
221
+ // Get base directory for validation (agileflowDir from policy or dest itself)
222
+ const baseDir = policy?.agileflowDir || dest;
223
+
204
224
  for (const entry of entries) {
205
225
  const srcPath = path.join(source, entry.name);
206
226
  const destPath = path.join(dest, entry.name);
207
227
 
228
+ // Validate destination path to prevent traversal attacks via malicious filenames
229
+ this.validateInstallPath(destPath, baseDir);
230
+
208
231
  if (entry.isDirectory()) {
209
232
  await fs.ensureDir(destPath);
210
233
  await this.copyContent(srcPath, destPath, agileflowFolder, policy);
@@ -688,18 +711,25 @@ class Installer {
688
711
  * @param {string} srcDir - Source directory
689
712
  * @param {string} destDir - Destination directory
690
713
  * @param {boolean} force - Overwrite existing files
714
+ * @param {string} [baseDir] - Base directory for path validation (defaults to destDir on first call)
691
715
  */
692
- async copyScriptsRecursive(srcDir, destDir, force) {
716
+ async copyScriptsRecursive(srcDir, destDir, force, baseDir = null) {
693
717
  const entries = await fs.readdir(srcDir, { withFileTypes: true });
694
718
 
719
+ // Use destDir as base for validation on first call
720
+ const validationBase = baseDir || destDir;
721
+
695
722
  for (const entry of entries) {
696
723
  const srcPath = path.join(srcDir, entry.name);
697
724
  const destPath = path.join(destDir, entry.name);
698
725
 
726
+ // Validate destination path to prevent traversal via malicious filenames
727
+ this.validateInstallPath(destPath, validationBase);
728
+
699
729
  if (entry.isDirectory()) {
700
730
  // Recursively copy subdirectories
701
731
  await fs.ensureDir(destPath);
702
- await this.copyScriptsRecursive(srcPath, destPath, force);
732
+ await this.copyScriptsRecursive(srcPath, destPath, force, validationBase);
703
733
  } else {
704
734
  // Copy file
705
735
  const destExists = await fs.pathExists(destPath);