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
package/lib/errors.js CHANGED
@@ -21,11 +21,214 @@ const { execSync } = require('child_process');
21
21
  // Debug logging (opt-in via AGILEFLOW_DEBUG=1)
22
22
  const DEBUG = process.env.AGILEFLOW_DEBUG === '1';
23
23
 
24
+ /**
25
+ * Patterns for detecting secrets that should be redacted from debug output.
26
+ * Each pattern captures the sensitive portion for replacement.
27
+ * ORDER MATTERS: More specific patterns should come before generic ones.
28
+ */
29
+ const SECRET_PATTERNS = [
30
+ // JWT tokens (header.payload.signature format) - MUST be before generic token pattern
31
+ {
32
+ pattern: /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g,
33
+ replacement: '***JWT_REDACTED***',
34
+ },
35
+
36
+ // NPM tokens - MUST be before generic token pattern
37
+ { pattern: /\bnpm_[A-Za-z0-9]{36}/g, replacement: '***NPM_TOKEN_REDACTED***' },
38
+ {
39
+ pattern: /\/\/registry\.npmjs\.org\/:_authToken=([A-Za-z0-9_-]+)/g,
40
+ replacement: '//registry.npmjs.org/:_authToken=***REDACTED***',
41
+ },
42
+
43
+ // GitHub tokens - MUST be before generic token pattern
44
+ { pattern: /\b(ghp_[A-Za-z0-9]{36})/g, replacement: '***GITHUB_TOKEN_REDACTED***' },
45
+ { pattern: /\b(github_pat_[A-Za-z0-9_]{22,})/g, replacement: '***GITHUB_PAT_REDACTED***' },
46
+
47
+ // AWS keys
48
+ { pattern: /\b(AKIA[A-Z0-9]{16})/g, replacement: '***AWS_KEY_REDACTED***' },
49
+ {
50
+ pattern: /\b(aws_secret_access_key)\s*[:=]\s*["']?([A-Za-z0-9/+=]{40})["']?/gi,
51
+ replacement: '$1=***REDACTED***',
52
+ },
53
+
54
+ // Bearer tokens
55
+ { pattern: /\bBearer\s+([A-Za-z0-9_.-]{20,})/gi, replacement: 'Bearer ***REDACTED***' },
56
+
57
+ // Git URLs with credentials
58
+ { pattern: /https?:\/\/([^:@\s]+):([^@\s]+)@/g, replacement: 'https://$1:***REDACTED***@' },
59
+
60
+ // Private keys (just detect, don't try to capture whole key)
61
+ {
62
+ pattern: /-----BEGIN\s+(RSA\s+)?PRIVATE\s+KEY-----/g,
63
+ replacement: '***PRIVATE_KEY_START_REDACTED***',
64
+ },
65
+
66
+ // Generic environment variable patterns (ALL_CAPS_WITH_SECRET_KEYWORD)
67
+ {
68
+ pattern:
69
+ /\b([A-Z_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH)[A-Z_]*)\s*[:=]\s*["']?([^\s"',}]{8,})["']?/g,
70
+ replacement: '$1=***REDACTED***',
71
+ },
72
+
73
+ // API keys and tokens (generic patterns) - lowercase, after env vars
74
+ {
75
+ pattern: /\b(api[_-]?key)\s*[:=]\s*["']?([A-Za-z0-9_-]{16,})["']?/gi,
76
+ replacement: '$1=***REDACTED***',
77
+ },
78
+ {
79
+ pattern: /\b(token)\s*[:=]\s*["']?([A-Za-z0-9_-]{16,})["']?/gi,
80
+ replacement: '$1=***REDACTED***',
81
+ },
82
+ {
83
+ pattern: /\b(secret)\s*[:=]\s*["']?([A-Za-z0-9_-]{16,})["']?/gi,
84
+ replacement: '$1=***REDACTED***',
85
+ },
86
+ {
87
+ pattern: /\b(password)\s*[:=]\s*["']?([^\s"',}]{4,})["']?/gi,
88
+ replacement: '$1=***REDACTED***',
89
+ },
90
+ { pattern: /\b(passwd)\s*[:=]\s*["']?([^\s"',}]{4,})["']?/gi, replacement: '$1=***REDACTED***' },
91
+ {
92
+ pattern: /\b(credentials?)\s*[:=]\s*["']?([^\s"',}]{4,})["']?/gi,
93
+ replacement: '$1=***REDACTED***',
94
+ },
95
+
96
+ // JSON-style "key":"value" patterns
97
+ {
98
+ pattern: /"(api[_-]?key|token|secret|password|passwd|credentials?)"\s*:\s*"([^"]{8,})"/gi,
99
+ replacement: '"$1":"***REDACTED***"',
100
+ },
101
+ ];
102
+
103
+ /**
104
+ * Sanitize debug output by redacting sensitive information.
105
+ * This function should be applied to all debug output that might contain secrets.
106
+ *
107
+ * @param {string|any} input - String or object to sanitize
108
+ * @returns {{ ok: boolean, sanitized: string, redactionCount: number }}
109
+ */
110
+ function sanitizeDebugOutput(input) {
111
+ // Handle non-string inputs by stringifying
112
+ let str;
113
+ if (typeof input === 'string') {
114
+ str = input;
115
+ } else if (input === null || input === undefined) {
116
+ return { ok: true, sanitized: String(input), redactionCount: 0 };
117
+ } else {
118
+ try {
119
+ str = JSON.stringify(input);
120
+ } catch {
121
+ str = String(input);
122
+ }
123
+ }
124
+
125
+ let redactionCount = 0;
126
+ let result = str;
127
+
128
+ for (const { pattern, replacement } of SECRET_PATTERNS) {
129
+ // Reset lastIndex for global patterns
130
+ pattern.lastIndex = 0;
131
+
132
+ // Count matches before replacing
133
+ const matches = result.match(pattern);
134
+ if (matches) {
135
+ redactionCount += matches.length;
136
+ result = result.replace(pattern, replacement);
137
+ }
138
+ }
139
+
140
+ return {
141
+ ok: true,
142
+ sanitized: result,
143
+ redactionCount,
144
+ };
145
+ }
146
+
24
147
  function debugLog(operation, details) {
25
148
  if (DEBUG) {
26
149
  const timestamp = new Date().toISOString();
27
- console.error(`[${timestamp}] [errors.js] ${operation}:`, JSON.stringify(details));
150
+ // Sanitize details to prevent secret leakage
151
+ const sanitized = sanitizeDebugOutput(details);
152
+ console.error(`[${timestamp}] [errors.js] ${operation}:`, sanitized.sanitized);
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Shell metacharacters that could enable command injection.
158
+ * These are dangerous in shell contexts:
159
+ * - ; | & - Command chaining
160
+ * - $ ` - Command substitution
161
+ * - ( ) { } - Subshells and brace expansion
162
+ * - < > - Redirects
163
+ * - \n \r - Newline injection
164
+ * - \ - Escape sequences
165
+ */
166
+ const SHELL_DANGEROUS_CHARS = /[;|&$`(){}<>\n\r\\]/;
167
+
168
+ /**
169
+ * Sanitize a string for safe use in shell commands.
170
+ * Rejects strings containing shell metacharacters.
171
+ *
172
+ * @param {string} str - String to sanitize
173
+ * @param {object} options - Optional settings
174
+ * @param {boolean} options.allowSpaces - Allow spaces in string (default: true)
175
+ * @param {string} options.context - Context description for error messages (default: 'argument')
176
+ * @returns {{ ok: boolean, sanitized?: string, error?: string, detected?: string }}
177
+ */
178
+ function sanitizeForShell(str, options = {}) {
179
+ const { allowSpaces = true, context = 'argument' } = options;
180
+
181
+ // Must be a string
182
+ if (typeof str !== 'string') {
183
+ return { ok: false, error: `Input must be a string, got ${typeof str}` };
184
+ }
185
+
186
+ // Empty strings are valid
187
+ if (str.length === 0) {
188
+ return { ok: true, sanitized: str };
189
+ }
190
+
191
+ // Check for dangerous shell metacharacters
192
+ const match = str.match(SHELL_DANGEROUS_CHARS);
193
+ if (match) {
194
+ const detected = match[0];
195
+ const charName = getCharName(detected);
196
+ return {
197
+ ok: false,
198
+ error: `Unsafe characters in ${context}: contains ${charName}`,
199
+ detected,
200
+ };
28
201
  }
202
+
203
+ // Optionally disallow spaces
204
+ if (!allowSpaces && /\s/.test(str)) {
205
+ return { ok: false, error: `Spaces not allowed in ${context}` };
206
+ }
207
+
208
+ return { ok: true, sanitized: str };
209
+ }
210
+
211
+ /**
212
+ * Get human-readable name for a shell character
213
+ */
214
+ function getCharName(char) {
215
+ const names = {
216
+ ';': 'semicolon (command separator)',
217
+ '|': 'pipe (command chaining)',
218
+ '&': 'ampersand (background/chaining)',
219
+ $: 'dollar sign (variable/command substitution)',
220
+ '`': 'backtick (command substitution)',
221
+ '(': 'open parenthesis (subshell)',
222
+ ')': 'close parenthesis (subshell)',
223
+ '{': 'open brace (brace expansion)',
224
+ '}': 'close brace (brace expansion)',
225
+ '<': 'less than (input redirect)',
226
+ '>': 'greater than (output redirect)',
227
+ '\n': 'newline (command injection)',
228
+ '\r': 'carriage return (command injection)',
229
+ '\\': 'backslash (escape sequence)',
230
+ };
231
+ return names[char] || `character '${char}'`;
29
232
  }
30
233
 
31
234
  /**
@@ -160,10 +363,20 @@ function safeWriteFile(filePath, content, options = {}) {
160
363
  * @param {string} options.cwd - Working directory
161
364
  * @param {number} options.timeout - Timeout in ms (default: 30000)
162
365
  * @param {boolean} options.silent - Suppress stderr (default: false)
366
+ * @param {boolean} options.sanitize - Validate command for shell injection (default: false)
163
367
  * @returns {{ ok: boolean, data?: string, error?: string, exitCode?: number }}
164
368
  */
165
369
  function safeExec(command, options = {}) {
166
- const { cwd = process.cwd(), timeout = 30000, silent = false } = options;
370
+ const { cwd = process.cwd(), timeout = 30000, silent = false, sanitize = false } = options;
371
+
372
+ // Optional sanitization check
373
+ if (sanitize) {
374
+ const sanitizeResult = sanitizeForShell(command, { context: 'command' });
375
+ if (!sanitizeResult.ok) {
376
+ debugLog('safeExec', { command: command.slice(0, 50), error: sanitizeResult.error });
377
+ return { ok: false, error: sanitizeResult.error, detected: sanitizeResult.detected };
378
+ }
379
+ }
167
380
 
168
381
  try {
169
382
  const output = execSync(command, {
@@ -245,9 +458,13 @@ function wrapSafe(fn, operationName = 'operation') {
245
458
  * Wrap an async function to catch errors and return Result
246
459
  * @param {Function} fn - Async function to wrap
247
460
  * @param {string} operationName - Name for error messages
248
- * @returns {Function} - Wrapped async function returning { ok, data?, error? }
461
+ * @param {object} [options] - Optional settings
462
+ * @param {boolean} [options.attachErrorCode=false] - Attach error code metadata
463
+ * @returns {Function} - Wrapped async function returning { ok, data?, error?, errorCode?, suggestedFix? }
249
464
  */
250
- function wrapSafeAsync(fn, operationName = 'operation') {
465
+ function wrapSafeAsync(fn, operationName = 'operation', options = {}) {
466
+ const { attachErrorCode = false } = options;
467
+
251
468
  return async function (...args) {
252
469
  try {
253
470
  const result = await fn.apply(this, args);
@@ -255,11 +472,117 @@ function wrapSafeAsync(fn, operationName = 'operation') {
255
472
  } catch (err) {
256
473
  const error = `${operationName} failed: ${err.message}`;
257
474
  debugLog('wrapSafeAsync', { operationName, error: err.message });
258
- return { ok: false, error };
475
+
476
+ const result = { ok: false, error };
477
+
478
+ // Optionally attach error code metadata
479
+ if (attachErrorCode) {
480
+ try {
481
+ const { getErrorCodeFromError } = require('./error-codes');
482
+ const codeData = getErrorCodeFromError(err);
483
+ result.errorCode = codeData.code;
484
+ result.severity = codeData.severity;
485
+ result.category = codeData.category;
486
+ result.recoverable = codeData.recoverable;
487
+ result.suggestedFix = codeData.suggestedFix;
488
+ result.autoFix = codeData.autoFix;
489
+ } catch {
490
+ // error-codes not available, skip
491
+ }
492
+ }
493
+
494
+ return result;
259
495
  }
260
496
  };
261
497
  }
262
498
 
499
+ /**
500
+ * Safely parse JSON string with optional field validation
501
+ * @param {string} content - JSON string to parse
502
+ * @param {object} options - Optional settings
503
+ * @param {string[]} options.requiredFields - Array of field names that must exist
504
+ * @param {object} options.schema - Simple schema: { fieldName: 'string' | 'number' | 'boolean' | 'object' | 'array' }
505
+ * @returns {{ ok: boolean, data?: any, error?: string, missingFields?: string[], invalidFields?: string[] }}
506
+ */
507
+ function safeParseJSON(content, options = {}) {
508
+ const { requiredFields = [], schema = {} } = options;
509
+
510
+ try {
511
+ if (typeof content !== 'string') {
512
+ return { ok: false, error: 'Content must be a string' };
513
+ }
514
+
515
+ const data = JSON.parse(content);
516
+ debugLog('safeParseJSON', { status: 'parsed' });
517
+
518
+ // Validate required fields
519
+ if (requiredFields.length > 0) {
520
+ const missingFields = requiredFields.filter(field => !(field in data));
521
+ if (missingFields.length > 0) {
522
+ const error = `Missing required fields: ${missingFields.join(', ')}`;
523
+ debugLog('safeParseJSON', { error, missingFields });
524
+ return { ok: false, error, missingFields };
525
+ }
526
+ }
527
+
528
+ // Validate field types from schema
529
+ if (Object.keys(schema).length > 0) {
530
+ const invalidFields = [];
531
+ for (const [field, expectedType] of Object.entries(schema)) {
532
+ if (field in data) {
533
+ const actualType = Array.isArray(data[field]) ? 'array' : typeof data[field];
534
+ if (actualType !== expectedType) {
535
+ invalidFields.push(`${field}: expected ${expectedType}, got ${actualType}`);
536
+ }
537
+ }
538
+ }
539
+ if (invalidFields.length > 0) {
540
+ const error = `Invalid field types: ${invalidFields.join('; ')}`;
541
+ debugLog('safeParseJSON', { error, invalidFields });
542
+ return { ok: false, error, invalidFields };
543
+ }
544
+ }
545
+
546
+ return { ok: true, data };
547
+ } catch (err) {
548
+ const error = `JSON parse error: ${err.message}`;
549
+ debugLog('safeParseJSON', { error: err.message });
550
+ return { ok: false, error };
551
+ }
552
+ }
553
+
554
+ /**
555
+ * Safely read and parse JSON file with validation
556
+ * @param {string} filePath - Absolute path to JSON file
557
+ * @param {object} options - Optional settings
558
+ * @param {*} options.defaultValue - Value to return if file doesn't exist
559
+ * @param {string[]} options.requiredFields - Array of field names that must exist
560
+ * @param {object} options.schema - Simple schema for type validation
561
+ * @returns {{ ok: boolean, data?: any, error?: string, missingFields?: string[], invalidFields?: string[] }}
562
+ */
563
+ function safeReadJSONWithValidation(filePath, options = {}) {
564
+ const { defaultValue, requiredFields, schema } = options;
565
+
566
+ try {
567
+ if (!fs.existsSync(filePath)) {
568
+ if (defaultValue !== undefined) {
569
+ debugLog('safeReadJSONWithValidation', { filePath, status: 'missing, using default' });
570
+ return { ok: true, data: defaultValue };
571
+ }
572
+ const error = `File not found: ${filePath}`;
573
+ debugLog('safeReadJSONWithValidation', { filePath, error });
574
+ return { ok: false, error };
575
+ }
576
+
577
+ const content = fs.readFileSync(filePath, 'utf8');
578
+ return safeParseJSON(content, { requiredFields, schema });
579
+ } catch (err) {
580
+ const error = `Failed to read JSON from ${filePath}: ${err.message}`;
581
+ debugLog('safeReadJSONWithValidation', { filePath, error: err.message });
582
+ return { ok: false, error };
583
+ }
584
+ }
585
+
263
586
  module.exports = {
264
587
  // Core safe operations
265
588
  safeReadJSON,
@@ -270,6 +593,14 @@ module.exports = {
270
593
  safeExists,
271
594
  safeMkdir,
272
595
 
596
+ // JSON parsing with validation
597
+ safeParseJSON,
598
+ safeReadJSONWithValidation,
599
+
600
+ // Security functions
601
+ sanitizeForShell,
602
+ sanitizeDebugOutput,
603
+
273
604
  // Utility wrappers
274
605
  wrapSafe,
275
606
  wrapSafeAsync,