agileflow 2.89.2 → 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.
Files changed (57) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +3 -3
  3. package/lib/content-sanitizer.js +463 -0
  4. package/lib/error-codes.js +544 -0
  5. package/lib/errors.js +336 -5
  6. package/lib/feedback.js +561 -0
  7. package/lib/path-resolver.js +396 -0
  8. package/lib/placeholder-registry.js +617 -0
  9. package/lib/session-registry.js +461 -0
  10. package/lib/smart-json-file.js +653 -0
  11. package/lib/table-formatter.js +504 -0
  12. package/lib/transient-status.js +374 -0
  13. package/lib/ui-manager.js +612 -0
  14. package/lib/validate-args.js +213 -0
  15. package/lib/validate-names.js +143 -0
  16. package/lib/validate-paths.js +434 -0
  17. package/lib/validate.js +38 -584
  18. package/package.json +4 -1
  19. package/scripts/agileflow-configure.js +40 -1440
  20. package/scripts/agileflow-welcome.js +2 -1
  21. package/scripts/check-update.js +16 -3
  22. package/scripts/lib/configure-detect.js +383 -0
  23. package/scripts/lib/configure-features.js +811 -0
  24. package/scripts/lib/configure-repair.js +314 -0
  25. package/scripts/lib/configure-utils.js +115 -0
  26. package/scripts/lib/frontmatter-parser.js +3 -3
  27. package/scripts/lib/sessionRegistry.js +682 -0
  28. package/scripts/obtain-context.js +417 -113
  29. package/scripts/ralph-loop.js +1 -1
  30. package/scripts/session-manager.js +77 -10
  31. package/scripts/tui/App.js +176 -0
  32. package/scripts/tui/index.js +75 -0
  33. package/scripts/tui/lib/crashRecovery.js +302 -0
  34. package/scripts/tui/lib/eventStream.js +316 -0
  35. package/scripts/tui/lib/keyboard.js +252 -0
  36. package/scripts/tui/lib/loopControl.js +371 -0
  37. package/scripts/tui/panels/OutputPanel.js +278 -0
  38. package/scripts/tui/panels/SessionPanel.js +178 -0
  39. package/scripts/tui/panels/TracePanel.js +333 -0
  40. package/src/core/commands/tui.md +91 -0
  41. package/tools/cli/commands/config.js +10 -33
  42. package/tools/cli/commands/doctor.js +48 -40
  43. package/tools/cli/commands/list.js +49 -37
  44. package/tools/cli/commands/status.js +13 -37
  45. package/tools/cli/commands/uninstall.js +12 -41
  46. package/tools/cli/installers/core/installer.js +75 -12
  47. package/tools/cli/installers/ide/_interface.js +238 -0
  48. package/tools/cli/installers/ide/codex.js +2 -2
  49. package/tools/cli/installers/ide/manager.js +15 -0
  50. package/tools/cli/lib/command-context.js +374 -0
  51. package/tools/cli/lib/config-manager.js +394 -0
  52. package/tools/cli/lib/content-injector.js +69 -16
  53. package/tools/cli/lib/ide-errors.js +163 -29
  54. package/tools/cli/lib/ide-registry.js +186 -0
  55. package/tools/cli/lib/npm-utils.js +16 -3
  56. package/tools/cli/lib/self-update.js +148 -0
  57. package/tools/cli/lib/validation-middleware.js +491 -0
@@ -0,0 +1,434 @@
1
+ /**
2
+ * AgileFlow CLI - Path Validation Utilities
3
+ *
4
+ * Path traversal protection and filesystem path validation.
5
+ */
6
+
7
+ const path = require('node:path');
8
+ const fs = require('node:fs');
9
+
10
+ /**
11
+ * Path validation error with context.
12
+ */
13
+ class PathValidationError extends Error {
14
+ /**
15
+ * @param {string} message - Error message
16
+ * @param {string} inputPath - The problematic path
17
+ * @param {string} reason - Reason for rejection
18
+ */
19
+ constructor(message, inputPath, reason) {
20
+ super(message);
21
+ this.name = 'PathValidationError';
22
+ this.inputPath = inputPath;
23
+ this.reason = reason;
24
+ Error.captureStackTrace(this, this.constructor);
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Check the depth of a symlink chain (how many symlinks to follow to reach final target).
30
+ * Returns early if chain exceeds maxDepth to prevent infinite loops from circular symlinks.
31
+ *
32
+ * @param {string} filePath - Starting path to check
33
+ * @param {number} maxDepth - Maximum allowed symlink chain depth
34
+ * @returns {{ ok: boolean, depth: number, error?: string, isCircular?: boolean }}
35
+ */
36
+ function checkSymlinkChainDepth(filePath, maxDepth) {
37
+ let current = filePath;
38
+ let depth = 0;
39
+ const seen = new Set();
40
+
41
+ // Loop until we find a non-symlink or exceed max depth
42
+ while (true) {
43
+ // Check for circular symlinks
44
+ if (seen.has(current)) {
45
+ return {
46
+ ok: false,
47
+ depth,
48
+ error: `Circular symlink detected at: ${current}`,
49
+ isCircular: true,
50
+ };
51
+ }
52
+ seen.add(current);
53
+
54
+ try {
55
+ const stats = fs.lstatSync(current);
56
+ if (!stats.isSymbolicLink()) {
57
+ // Reached a real file/directory, chain ends
58
+ return { ok: true, depth };
59
+ }
60
+
61
+ // Increment depth before checking limit
62
+ depth++;
63
+
64
+ // Check if we've exceeded max depth
65
+ if (depth > maxDepth) {
66
+ return {
67
+ ok: false,
68
+ depth,
69
+ error: `Symlink chain depth (${depth}) exceeds maximum (${maxDepth})`,
70
+ };
71
+ }
72
+
73
+ // Read symlink target
74
+ const target = fs.readlinkSync(current);
75
+
76
+ // Resolve target path (could be relative or absolute)
77
+ if (path.isAbsolute(target)) {
78
+ current = target;
79
+ } else {
80
+ current = path.resolve(path.dirname(current), target);
81
+ }
82
+ } catch (e) {
83
+ if (e.code === 'ENOENT') {
84
+ // Path doesn't exist, chain ends here
85
+ return { ok: true, depth };
86
+ }
87
+ // Other error (permission denied, etc.)
88
+ return { ok: true, depth };
89
+ }
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Validate that a path is safe and within the allowed base directory.
95
+ * Prevents path traversal attacks by:
96
+ * 1. Resolving the path to absolute form
97
+ * 2. Ensuring it stays within the base directory
98
+ * 3. Rejecting symbolic links (optional)
99
+ * 4. When symlinks allowed, verifying symlink targets stay within base
100
+ * 5. Limiting symlink chain depth to prevent infinite loops
101
+ *
102
+ * @param {string} inputPath - The path to validate (can be relative or absolute)
103
+ * @param {string} baseDir - The allowed base directory (must be absolute)
104
+ * @param {Object} options - Validation options
105
+ * @param {boolean} [options.allowSymlinks=false] - Allow symbolic links
106
+ * @param {boolean} [options.mustExist=false] - Path must exist on filesystem
107
+ * @param {number} [options.maxSymlinkDepth=3] - Maximum symlink chain depth (when symlinks allowed)
108
+ * @returns {{ ok: boolean, resolvedPath?: string, realPath?: string, error?: PathValidationError }}
109
+ *
110
+ * @example
111
+ * // Validate a file path within project directory
112
+ * const result = validatePath('./config.yaml', '/home/user/project');
113
+ * if (result.ok) {
114
+ * console.log('Safe path:', result.resolvedPath);
115
+ * }
116
+ *
117
+ * @example
118
+ * // Reject path traversal attempt
119
+ * const result = validatePath('../../../etc/passwd', '/home/user/project');
120
+ * // result.ok === false
121
+ * // result.error.reason === 'path_traversal'
122
+ *
123
+ * @example
124
+ * // Reject deep symlink chains
125
+ * const result = validatePath('link1', baseDir, { allowSymlinks: true, maxSymlinkDepth: 3 });
126
+ * // If link1 -> link2 -> link3 -> link4 -> target, this fails with 'symlink_chain_too_deep'
127
+ */
128
+ function validatePath(inputPath, baseDir, options = {}) {
129
+ const { allowSymlinks = false, mustExist = false, maxSymlinkDepth = 3 } = options;
130
+
131
+ // Input validation
132
+ if (!inputPath || typeof inputPath !== 'string') {
133
+ return {
134
+ ok: false,
135
+ error: new PathValidationError(
136
+ 'Path is required and must be a string',
137
+ String(inputPath),
138
+ 'invalid_input'
139
+ ),
140
+ };
141
+ }
142
+
143
+ if (!baseDir || typeof baseDir !== 'string') {
144
+ return {
145
+ ok: false,
146
+ error: new PathValidationError(
147
+ 'Base directory is required and must be a string',
148
+ inputPath,
149
+ 'invalid_base'
150
+ ),
151
+ };
152
+ }
153
+
154
+ // Base directory must be absolute
155
+ if (!path.isAbsolute(baseDir)) {
156
+ return {
157
+ ok: false,
158
+ error: new PathValidationError(
159
+ 'Base directory must be an absolute path',
160
+ inputPath,
161
+ 'relative_base'
162
+ ),
163
+ };
164
+ }
165
+
166
+ // Normalize the base directory
167
+ const normalizedBase = path.resolve(baseDir);
168
+
169
+ // Resolve the input path relative to base directory
170
+ let resolvedPath;
171
+ if (path.isAbsolute(inputPath)) {
172
+ resolvedPath = path.resolve(inputPath);
173
+ } else {
174
+ resolvedPath = path.resolve(normalizedBase, inputPath);
175
+ }
176
+
177
+ // Helper function to check if path is within base
178
+ const checkWithinBase = pathToCheck => {
179
+ const baseWithSep = normalizedBase.endsWith(path.sep)
180
+ ? normalizedBase
181
+ : normalizedBase + path.sep;
182
+ return pathToCheck === normalizedBase || pathToCheck.startsWith(baseWithSep);
183
+ };
184
+
185
+ // Check for path traversal: resolved path must start with base directory
186
+ if (!checkWithinBase(resolvedPath)) {
187
+ return {
188
+ ok: false,
189
+ error: new PathValidationError(
190
+ `Path escapes base directory: ${inputPath}`,
191
+ inputPath,
192
+ 'path_traversal'
193
+ ),
194
+ };
195
+ }
196
+
197
+ // Check if path exists (if required)
198
+ if (mustExist) {
199
+ try {
200
+ fs.accessSync(resolvedPath);
201
+ } catch {
202
+ return {
203
+ ok: false,
204
+ error: new PathValidationError(
205
+ `Path does not exist: ${resolvedPath}`,
206
+ inputPath,
207
+ 'not_found'
208
+ ),
209
+ };
210
+ }
211
+ }
212
+
213
+ // Check for symbolic links
214
+ if (!allowSymlinks) {
215
+ // Symlinks not allowed - reject if found
216
+ try {
217
+ const stats = fs.lstatSync(resolvedPath);
218
+ if (stats.isSymbolicLink()) {
219
+ return {
220
+ ok: false,
221
+ error: new PathValidationError(
222
+ `Symbolic links are not allowed: ${inputPath}`,
223
+ inputPath,
224
+ 'symlink_rejected'
225
+ ),
226
+ };
227
+ }
228
+ } catch {
229
+ // Path doesn't exist yet, which is fine if mustExist is false
230
+ // Check parent directories for symlinks
231
+ const parts = path.relative(normalizedBase, resolvedPath).split(path.sep);
232
+ let currentPath = normalizedBase;
233
+
234
+ for (const part of parts) {
235
+ currentPath = path.join(currentPath, part);
236
+ try {
237
+ const stats = fs.lstatSync(currentPath);
238
+ if (stats.isSymbolicLink()) {
239
+ return {
240
+ ok: false,
241
+ error: new PathValidationError(
242
+ `Path contains symbolic link: ${currentPath}`,
243
+ inputPath,
244
+ 'symlink_in_path'
245
+ ),
246
+ };
247
+ }
248
+ } catch {
249
+ // Part of path doesn't exist, stop checking
250
+ break;
251
+ }
252
+ }
253
+ }
254
+ } else {
255
+ // Symlinks allowed - but we must verify the target stays within base!
256
+ // This prevents symlink-based escape attacks
257
+ try {
258
+ const stats = fs.lstatSync(resolvedPath);
259
+ if (stats.isSymbolicLink()) {
260
+ // Check symlink chain depth to prevent DoS via infinite loops
261
+ const chainResult = checkSymlinkChainDepth(resolvedPath, maxSymlinkDepth);
262
+ if (!chainResult.ok) {
263
+ const reason = chainResult.isCircular ? 'symlink_circular' : 'symlink_chain_too_deep';
264
+ return {
265
+ ok: false,
266
+ error: new PathValidationError(chainResult.error, inputPath, reason),
267
+ };
268
+ }
269
+
270
+ // Resolve the symlink target to its real path
271
+ const realPath = fs.realpathSync(resolvedPath);
272
+
273
+ // Verify the real path is also within base directory
274
+ if (!checkWithinBase(realPath)) {
275
+ return {
276
+ ok: false,
277
+ error: new PathValidationError(
278
+ `Symlink target escapes base directory: ${inputPath} -> ${realPath}`,
279
+ inputPath,
280
+ 'symlink_escape'
281
+ ),
282
+ };
283
+ }
284
+
285
+ // Return both the resolved path and the real path
286
+ return {
287
+ ok: true,
288
+ resolvedPath,
289
+ realPath,
290
+ };
291
+ }
292
+ } catch {
293
+ // Path doesn't exist - that's okay for non-mustExist scenarios
294
+ // Also check parent directories for symlinks that might escape
295
+ const parts = path.relative(normalizedBase, resolvedPath).split(path.sep);
296
+ let currentPath = normalizedBase;
297
+
298
+ for (const part of parts) {
299
+ currentPath = path.join(currentPath, part);
300
+ try {
301
+ const stats = fs.lstatSync(currentPath);
302
+ if (stats.isSymbolicLink()) {
303
+ // Check symlink chain depth
304
+ const chainResult = checkSymlinkChainDepth(currentPath, maxSymlinkDepth);
305
+ if (!chainResult.ok) {
306
+ const reason = chainResult.isCircular ? 'symlink_circular' : 'symlink_chain_too_deep';
307
+ return {
308
+ ok: false,
309
+ error: new PathValidationError(chainResult.error, inputPath, reason),
310
+ };
311
+ }
312
+
313
+ // Resolve this symlink and check its target
314
+ const realPath = fs.realpathSync(currentPath);
315
+ if (!checkWithinBase(realPath)) {
316
+ return {
317
+ ok: false,
318
+ error: new PathValidationError(
319
+ `Path contains symlink escaping base: ${currentPath} -> ${realPath}`,
320
+ inputPath,
321
+ 'symlink_escape'
322
+ ),
323
+ };
324
+ }
325
+ }
326
+ } catch {
327
+ // Part of path doesn't exist, stop checking
328
+ break;
329
+ }
330
+ }
331
+ }
332
+ }
333
+
334
+ return {
335
+ ok: true,
336
+ resolvedPath,
337
+ };
338
+ }
339
+
340
+ /**
341
+ * Synchronous version that throws on invalid paths.
342
+ * Use when you want exceptions rather than result objects.
343
+ *
344
+ * @param {string} inputPath - The path to validate
345
+ * @param {string} baseDir - The allowed base directory
346
+ * @param {Object} options - Validation options
347
+ * @returns {string} The validated absolute path
348
+ * @throws {PathValidationError} If path is invalid
349
+ */
350
+ function validatePathSync(inputPath, baseDir, options = {}) {
351
+ const result = validatePath(inputPath, baseDir, options);
352
+ if (!result.ok) {
353
+ throw result.error;
354
+ }
355
+ return result.resolvedPath;
356
+ }
357
+
358
+ /**
359
+ * Check if a path contains dangerous patterns without resolving.
360
+ * Useful for quick pre-validation before expensive operations.
361
+ *
362
+ * @param {string} inputPath - The path to check
363
+ * @returns {{ safe: boolean, reason?: string }}
364
+ */
365
+ function hasUnsafePathPatterns(inputPath) {
366
+ if (!inputPath || typeof inputPath !== 'string') {
367
+ return { safe: false, reason: 'invalid_input' };
368
+ }
369
+
370
+ // Check for null bytes (can bypass security in some systems)
371
+ if (inputPath.includes('\0')) {
372
+ return { safe: false, reason: 'null_byte' };
373
+ }
374
+
375
+ // Check for obvious traversal patterns
376
+ if (inputPath.includes('..')) {
377
+ return { safe: false, reason: 'dot_dot_sequence' };
378
+ }
379
+
380
+ // Check for absolute paths on Unix when expecting relative
381
+ if (inputPath.startsWith('/') && !path.isAbsolute(inputPath)) {
382
+ return { safe: false, reason: 'unexpected_absolute' };
383
+ }
384
+
385
+ // Check for Windows-style absolute paths
386
+ if (/^[a-zA-Z]:/.test(inputPath)) {
387
+ return { safe: false, reason: 'windows_absolute' };
388
+ }
389
+
390
+ return { safe: true };
391
+ }
392
+
393
+ /**
394
+ * Sanitize a filename by removing dangerous characters.
395
+ * Does NOT validate the full path - use with validatePath().
396
+ *
397
+ * @param {string} filename - The filename to sanitize
398
+ * @param {Object} options - Sanitization options
399
+ * @param {string} [options.replacement='_'] - Character to replace with
400
+ * @param {number} [options.maxLength=255] - Maximum filename length
401
+ * @returns {string} Sanitized filename
402
+ */
403
+ function sanitizeFilename(filename, options = {}) {
404
+ const { replacement = '_', maxLength = 255 } = options;
405
+
406
+ if (!filename || typeof filename !== 'string') {
407
+ return '';
408
+ }
409
+
410
+ // Remove or replace dangerous characters
411
+ let sanitized = filename
412
+ .replace(/[<>:"/\\|?*\x00-\x1f]/g, replacement) // Control chars and reserved
413
+ .replace(/\.{2,}/g, replacement) // Multiple dots
414
+ .replace(/^\.+/, replacement) // Leading dots
415
+ .replace(/^-+/, replacement); // Leading dashes (prevent flag injection)
416
+
417
+ // Truncate if too long
418
+ if (sanitized.length > maxLength) {
419
+ const ext = path.extname(sanitized);
420
+ const base = path.basename(sanitized, ext);
421
+ sanitized = base.slice(0, maxLength - ext.length) + ext;
422
+ }
423
+
424
+ return sanitized;
425
+ }
426
+
427
+ module.exports = {
428
+ PathValidationError,
429
+ checkSymlinkChainDepth,
430
+ validatePath,
431
+ validatePathSync,
432
+ hasUnsafePathPatterns,
433
+ sanitizeFilename,
434
+ };