agileflow 2.89.1 → 2.89.3

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.
@@ -0,0 +1,449 @@
1
+ /**
2
+ * smart-json-file.js - Safe JSON File Operations with Retry Logic
3
+ *
4
+ * Provides atomic writes, automatic retries, and error code integration
5
+ * for all JSON file operations in AgileFlow.
6
+ *
7
+ * Features:
8
+ * - Atomic writes (write to temp file, then rename)
9
+ * - Configurable retry logic with exponential backoff
10
+ * - Integration with error-codes.js for typed errors
11
+ * - Schema validation support
12
+ * - Automatic directory creation
13
+ *
14
+ * Usage:
15
+ * const SmartJsonFile = require('./smart-json-file');
16
+ *
17
+ * // Basic usage
18
+ * const file = new SmartJsonFile('/path/to/file.json');
19
+ * const data = await file.read();
20
+ * await file.write({ key: 'value' });
21
+ *
22
+ * // With options
23
+ * const file = new SmartJsonFile('/path/to/file.json', {
24
+ * retries: 3,
25
+ * backoff: 100,
26
+ * createDir: true,
27
+ * schema: mySchema
28
+ * });
29
+ */
30
+
31
+ const fs = require('fs');
32
+ const path = require('path');
33
+ const { createTypedError, getErrorCodeFromError, ErrorCodes } = require('./error-codes');
34
+
35
+ // Debug logging
36
+ const DEBUG = process.env.AGILEFLOW_DEBUG === '1';
37
+
38
+ function debugLog(operation, details) {
39
+ if (DEBUG) {
40
+ const timestamp = new Date().toISOString();
41
+ console.error(`[${timestamp}] [SmartJsonFile] ${operation}:`, JSON.stringify(details));
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Generate a unique temporary file path
47
+ * @param {string} filePath - Original file path
48
+ * @returns {string} Temporary file path
49
+ */
50
+ function getTempPath(filePath) {
51
+ const dir = path.dirname(filePath);
52
+ const ext = path.extname(filePath);
53
+ const base = path.basename(filePath, ext);
54
+ const timestamp = Date.now();
55
+ const random = Math.random().toString(36).slice(2, 8);
56
+ return path.join(dir, `.${base}.${timestamp}.${random}${ext}.tmp`);
57
+ }
58
+
59
+ /**
60
+ * Sleep for a specified duration
61
+ * @param {number} ms - Milliseconds to sleep
62
+ * @returns {Promise<void>}
63
+ */
64
+ function sleep(ms) {
65
+ return new Promise(resolve => setTimeout(resolve, ms));
66
+ }
67
+
68
+ /**
69
+ * SmartJsonFile - Safe JSON file operations with retry logic
70
+ */
71
+ class SmartJsonFile {
72
+ /**
73
+ * @param {string} filePath - Absolute path to JSON file
74
+ * @param {Object} [options={}] - Configuration options
75
+ * @param {number} [options.retries=3] - Number of retry attempts
76
+ * @param {number} [options.backoff=100] - Initial backoff in ms (doubles each retry)
77
+ * @param {boolean} [options.createDir=true] - Create parent directory if missing
78
+ * @param {number} [options.spaces=2] - JSON indentation spaces
79
+ * @param {Function} [options.schema] - Optional validation function (throws on invalid)
80
+ * @param {*} [options.defaultValue] - Default value if file doesn't exist
81
+ */
82
+ constructor(filePath, options = {}) {
83
+ if (!filePath || typeof filePath !== 'string') {
84
+ throw createTypedError('filePath is required and must be a string', 'EINVAL', {
85
+ context: { provided: typeof filePath },
86
+ });
87
+ }
88
+
89
+ if (!path.isAbsolute(filePath)) {
90
+ throw createTypedError('filePath must be an absolute path', 'EINVAL', {
91
+ context: { provided: filePath },
92
+ });
93
+ }
94
+
95
+ this.filePath = filePath;
96
+ this.retries = options.retries ?? 3;
97
+ this.backoff = options.backoff ?? 100;
98
+ this.createDir = options.createDir ?? true;
99
+ this.spaces = options.spaces ?? 2;
100
+ this.schema = options.schema ?? null;
101
+ this.defaultValue = options.defaultValue;
102
+
103
+ debugLog('constructor', {
104
+ filePath,
105
+ options: { retries: this.retries, backoff: this.backoff },
106
+ });
107
+ }
108
+
109
+ /**
110
+ * Read and parse JSON file
111
+ * @returns {Promise<{ok: boolean, data?: any, error?: Error}>} Result object
112
+ */
113
+ async read() {
114
+ let lastError = null;
115
+ let attempt = 0;
116
+
117
+ while (attempt <= this.retries) {
118
+ try {
119
+ debugLog('read', { filePath: this.filePath, attempt });
120
+
121
+ // Check if file exists
122
+ if (!fs.existsSync(this.filePath)) {
123
+ if (this.defaultValue !== undefined) {
124
+ debugLog('read', { status: 'using default value' });
125
+ return { ok: true, data: this.defaultValue };
126
+ }
127
+ const error = createTypedError(`File not found: ${this.filePath}`, 'ENOENT', {
128
+ context: { filePath: this.filePath },
129
+ });
130
+ return { ok: false, error };
131
+ }
132
+
133
+ // Read file
134
+ const content = fs.readFileSync(this.filePath, 'utf8');
135
+
136
+ // Parse JSON
137
+ let data;
138
+ try {
139
+ data = JSON.parse(content);
140
+ } catch (parseError) {
141
+ const error = createTypedError(
142
+ `Invalid JSON in ${this.filePath}: ${parseError.message}`,
143
+ 'EPARSE',
144
+ { cause: parseError, context: { filePath: this.filePath } }
145
+ );
146
+ return { ok: false, error };
147
+ }
148
+
149
+ // Validate schema if provided
150
+ if (this.schema) {
151
+ try {
152
+ this.schema(data);
153
+ } catch (schemaError) {
154
+ const error = createTypedError(
155
+ `Schema validation failed for ${this.filePath}: ${schemaError.message}`,
156
+ 'ESCHEMA',
157
+ { cause: schemaError, context: { filePath: this.filePath } }
158
+ );
159
+ return { ok: false, error };
160
+ }
161
+ }
162
+
163
+ debugLog('read', { status: 'success' });
164
+ return { ok: true, data };
165
+ } catch (err) {
166
+ lastError = err;
167
+ debugLog('read', { status: 'error', error: err.message, attempt });
168
+
169
+ // Don't retry for certain errors
170
+ const errorCode = getErrorCodeFromError(err);
171
+ if (errorCode.code === 'EACCES' || errorCode.code === 'EPERM') {
172
+ // Permission errors won't resolve with retries
173
+ const error = createTypedError(`Permission denied reading ${this.filePath}`, 'EACCES', {
174
+ cause: err,
175
+ context: { filePath: this.filePath },
176
+ });
177
+ return { ok: false, error };
178
+ }
179
+
180
+ // Wait before retrying
181
+ if (attempt < this.retries) {
182
+ const waitTime = this.backoff * Math.pow(2, attempt);
183
+ debugLog('read', { status: 'retrying', waitTime });
184
+ await sleep(waitTime);
185
+ }
186
+
187
+ attempt++;
188
+ }
189
+ }
190
+
191
+ // All retries exhausted
192
+ const error = createTypedError(
193
+ `Failed to read ${this.filePath} after ${this.retries + 1} attempts: ${lastError?.message}`,
194
+ 'EUNKNOWN',
195
+ { cause: lastError, context: { filePath: this.filePath, attempts: this.retries + 1 } }
196
+ );
197
+ return { ok: false, error };
198
+ }
199
+
200
+ /**
201
+ * Write data to JSON file atomically
202
+ * @param {any} data - Data to write
203
+ * @returns {Promise<{ok: boolean, error?: Error}>} Result object
204
+ */
205
+ async write(data) {
206
+ let lastError = null;
207
+ let attempt = 0;
208
+
209
+ // Validate schema before writing
210
+ if (this.schema) {
211
+ try {
212
+ this.schema(data);
213
+ } catch (schemaError) {
214
+ const error = createTypedError(
215
+ `Schema validation failed: ${schemaError.message}`,
216
+ 'ESCHEMA',
217
+ { cause: schemaError, context: { filePath: this.filePath } }
218
+ );
219
+ return { ok: false, error };
220
+ }
221
+ }
222
+
223
+ while (attempt <= this.retries) {
224
+ const tempPath = getTempPath(this.filePath);
225
+
226
+ try {
227
+ debugLog('write', { filePath: this.filePath, tempPath, attempt });
228
+
229
+ // Create parent directory if needed
230
+ const dir = path.dirname(this.filePath);
231
+ if (this.createDir && !fs.existsSync(dir)) {
232
+ fs.mkdirSync(dir, { recursive: true });
233
+ debugLog('write', { status: 'created directory', dir });
234
+ }
235
+
236
+ // Serialize data
237
+ const content = JSON.stringify(data, null, this.spaces) + '\n';
238
+
239
+ // Write to temp file
240
+ fs.writeFileSync(tempPath, content, 'utf8');
241
+ debugLog('write', { status: 'wrote temp file' });
242
+
243
+ // Atomic rename
244
+ fs.renameSync(tempPath, this.filePath);
245
+ debugLog('write', { status: 'success' });
246
+
247
+ return { ok: true };
248
+ } catch (err) {
249
+ lastError = err;
250
+ debugLog('write', { status: 'error', error: err.message, attempt });
251
+
252
+ // Clean up temp file if it exists
253
+ try {
254
+ if (fs.existsSync(tempPath)) {
255
+ fs.unlinkSync(tempPath);
256
+ }
257
+ } catch {
258
+ // Ignore cleanup errors
259
+ }
260
+
261
+ // Don't retry for certain errors
262
+ const errorCode = getErrorCodeFromError(err);
263
+ if (
264
+ errorCode.code === 'EACCES' ||
265
+ errorCode.code === 'EPERM' ||
266
+ errorCode.code === 'EROFS'
267
+ ) {
268
+ const error = createTypedError(
269
+ `Permission denied writing ${this.filePath}`,
270
+ errorCode.code,
271
+ { cause: err, context: { filePath: this.filePath } }
272
+ );
273
+ return { ok: false, error };
274
+ }
275
+
276
+ // Wait before retrying
277
+ if (attempt < this.retries) {
278
+ const waitTime = this.backoff * Math.pow(2, attempt);
279
+ debugLog('write', { status: 'retrying', waitTime });
280
+ await sleep(waitTime);
281
+ }
282
+
283
+ attempt++;
284
+ }
285
+ }
286
+
287
+ // All retries exhausted
288
+ const error = createTypedError(
289
+ `Failed to write ${this.filePath} after ${this.retries + 1} attempts: ${lastError?.message}`,
290
+ 'EUNKNOWN',
291
+ { cause: lastError, context: { filePath: this.filePath, attempts: this.retries + 1 } }
292
+ );
293
+ return { ok: false, error };
294
+ }
295
+
296
+ /**
297
+ * Read, modify, and write atomically
298
+ * @param {Function} modifier - Function that takes current data and returns modified data
299
+ * @returns {Promise<{ok: boolean, data?: any, error?: Error}>} Result object
300
+ */
301
+ async modify(modifier) {
302
+ debugLog('modify', { filePath: this.filePath });
303
+
304
+ // Read current data
305
+ const readResult = await this.read();
306
+ if (!readResult.ok) {
307
+ // If file doesn't exist but we have a default value, use that
308
+ if (readResult.error?.errorCode === 'ENOENT' && this.defaultValue !== undefined) {
309
+ readResult.ok = true;
310
+ readResult.data = this.defaultValue;
311
+ delete readResult.error;
312
+ } else {
313
+ return readResult;
314
+ }
315
+ }
316
+
317
+ // Apply modifier
318
+ let newData;
319
+ try {
320
+ newData = await modifier(readResult.data);
321
+ } catch (modifyError) {
322
+ const error = createTypedError(`Modifier function failed: ${modifyError.message}`, 'EINVAL', {
323
+ cause: modifyError,
324
+ context: { filePath: this.filePath },
325
+ });
326
+ return { ok: false, error };
327
+ }
328
+
329
+ // Write modified data
330
+ const writeResult = await this.write(newData);
331
+ if (!writeResult.ok) {
332
+ return writeResult;
333
+ }
334
+
335
+ return { ok: true, data: newData };
336
+ }
337
+
338
+ /**
339
+ * Check if file exists
340
+ * @returns {boolean}
341
+ */
342
+ exists() {
343
+ return fs.existsSync(this.filePath);
344
+ }
345
+
346
+ /**
347
+ * Delete the file
348
+ * @returns {Promise<{ok: boolean, error?: Error}>}
349
+ */
350
+ async delete() {
351
+ try {
352
+ if (!fs.existsSync(this.filePath)) {
353
+ return { ok: true }; // Already doesn't exist
354
+ }
355
+
356
+ fs.unlinkSync(this.filePath);
357
+ debugLog('delete', { filePath: this.filePath, status: 'success' });
358
+ return { ok: true };
359
+ } catch (err) {
360
+ const errorCode = getErrorCodeFromError(err);
361
+ const error = createTypedError(
362
+ `Failed to delete ${this.filePath}: ${err.message}`,
363
+ errorCode.code,
364
+ { cause: err, context: { filePath: this.filePath } }
365
+ );
366
+ return { ok: false, error };
367
+ }
368
+ }
369
+
370
+ /**
371
+ * Synchronous read - for cases where async isn't possible
372
+ * @returns {{ok: boolean, data?: any, error?: Error}}
373
+ */
374
+ readSync() {
375
+ try {
376
+ if (!fs.existsSync(this.filePath)) {
377
+ if (this.defaultValue !== undefined) {
378
+ return { ok: true, data: this.defaultValue };
379
+ }
380
+ const error = createTypedError(`File not found: ${this.filePath}`, 'ENOENT', {
381
+ context: { filePath: this.filePath },
382
+ });
383
+ return { ok: false, error };
384
+ }
385
+
386
+ const content = fs.readFileSync(this.filePath, 'utf8');
387
+ const data = JSON.parse(content);
388
+
389
+ if (this.schema) {
390
+ this.schema(data);
391
+ }
392
+
393
+ return { ok: true, data };
394
+ } catch (err) {
395
+ const errorCode = getErrorCodeFromError(err);
396
+ const error = createTypedError(
397
+ `Failed to read ${this.filePath}: ${err.message}`,
398
+ errorCode.code,
399
+ { cause: err, context: { filePath: this.filePath } }
400
+ );
401
+ return { ok: false, error };
402
+ }
403
+ }
404
+
405
+ /**
406
+ * Synchronous write - for cases where async isn't possible
407
+ * @param {any} data - Data to write
408
+ * @returns {{ok: boolean, error?: Error}}
409
+ */
410
+ writeSync(data) {
411
+ const tempPath = getTempPath(this.filePath);
412
+
413
+ try {
414
+ if (this.schema) {
415
+ this.schema(data);
416
+ }
417
+
418
+ const dir = path.dirname(this.filePath);
419
+ if (this.createDir && !fs.existsSync(dir)) {
420
+ fs.mkdirSync(dir, { recursive: true });
421
+ }
422
+
423
+ const content = JSON.stringify(data, null, this.spaces) + '\n';
424
+ fs.writeFileSync(tempPath, content, 'utf8');
425
+ fs.renameSync(tempPath, this.filePath);
426
+
427
+ return { ok: true };
428
+ } catch (err) {
429
+ // Clean up temp file
430
+ try {
431
+ if (fs.existsSync(tempPath)) {
432
+ fs.unlinkSync(tempPath);
433
+ }
434
+ } catch {
435
+ // Ignore
436
+ }
437
+
438
+ const errorCode = getErrorCodeFromError(err);
439
+ const error = createTypedError(
440
+ `Failed to write ${this.filePath}: ${err.message}`,
441
+ errorCode.code,
442
+ { cause: err, context: { filePath: this.filePath } }
443
+ );
444
+ return { ok: false, error };
445
+ }
446
+ }
447
+ }
448
+
449
+ module.exports = SmartJsonFile;
package/lib/validate.js CHANGED
@@ -345,19 +345,87 @@ class PathValidationError extends Error {
345
345
  }
346
346
  }
347
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
+
348
413
  /**
349
414
  * Validate that a path is safe and within the allowed base directory.
350
415
  * Prevents path traversal attacks by:
351
416
  * 1. Resolving the path to absolute form
352
417
  * 2. Ensuring it stays within the base directory
353
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
354
421
  *
355
422
  * @param {string} inputPath - The path to validate (can be relative or absolute)
356
423
  * @param {string} baseDir - The allowed base directory (must be absolute)
357
424
  * @param {Object} options - Validation options
358
425
  * @param {boolean} [options.allowSymlinks=false] - Allow symbolic links
359
426
  * @param {boolean} [options.mustExist=false] - Path must exist on filesystem
360
- * @returns {{ ok: boolean, resolvedPath?: string, error?: PathValidationError }}
427
+ * @param {number} [options.maxSymlinkDepth=3] - Maximum symlink chain depth (when symlinks allowed)
428
+ * @returns {{ ok: boolean, resolvedPath?: string, realPath?: string, error?: PathValidationError }}
361
429
  *
362
430
  * @example
363
431
  * // Validate a file path within project directory
@@ -371,9 +439,14 @@ class PathValidationError extends Error {
371
439
  * const result = validatePath('../../../etc/passwd', '/home/user/project');
372
440
  * // result.ok === false
373
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'
374
447
  */
375
448
  function validatePath(inputPath, baseDir, options = {}) {
376
- const { allowSymlinks = false, mustExist = false } = options;
449
+ const { allowSymlinks = false, mustExist = false, maxSymlinkDepth = 3 } = options;
377
450
 
378
451
  // Input validation
379
452
  if (!inputPath || typeof inputPath !== 'string') {
@@ -421,15 +494,16 @@ function validatePath(inputPath, baseDir, options = {}) {
421
494
  resolvedPath = path.resolve(normalizedBase, inputPath);
422
495
  }
423
496
 
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 = resolvedPath === normalizedBase || resolvedPath.startsWith(baseWithSep);
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
+ };
431
504
 
432
- if (!isWithinBase) {
505
+ // Check for path traversal: resolved path must start with base directory
506
+ if (!checkWithinBase(resolvedPath)) {
433
507
  return {
434
508
  ok: false,
435
509
  error: new PathValidationError(
@@ -456,8 +530,9 @@ function validatePath(inputPath, baseDir, options = {}) {
456
530
  }
457
531
  }
458
532
 
459
- // Check for symbolic links (if not allowed)
533
+ // Check for symbolic links
460
534
  if (!allowSymlinks) {
535
+ // Symlinks not allowed - reject if found
461
536
  try {
462
537
  const stats = fs.lstatSync(resolvedPath);
463
538
  if (stats.isSymbolicLink()) {
@@ -496,6 +571,84 @@ function validatePath(inputPath, baseDir, options = {}) {
496
571
  }
497
572
  }
498
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
+ }
499
652
  }
500
653
 
501
654
  return {
@@ -613,4 +766,5 @@ module.exports = {
613
766
  validatePathSync,
614
767
  hasUnsafePathPatterns,
615
768
  sanitizeFilename,
769
+ checkSymlinkChainDepth,
616
770
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agileflow",
3
- "version": "2.89.1",
3
+ "version": "2.89.3",
4
4
  "description": "AI-driven agile development system for Claude Code, Cursor, Windsurf, and more",
5
5
  "keywords": [
6
6
  "agile",