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.
@@ -33,6 +33,13 @@ const {
33
33
  countSkills,
34
34
  getCounts,
35
35
  } = require('../../../scripts/lib/counter');
36
+ const {
37
+ sanitize,
38
+ sanitizeAgentData,
39
+ sanitizeCommandData,
40
+ validatePlaceholderValue,
41
+ detectInjectionAttempt,
42
+ } = require('../../../lib/content-sanitizer');
36
43
 
37
44
  // =============================================================================
38
45
  // List Generation Functions
@@ -41,12 +48,14 @@ const {
41
48
  /**
42
49
  * Validate that a file path is within the expected directory.
43
50
  * Prevents reading files outside the expected scope.
51
+ * Security: Symlinks are NOT allowed to prevent escape attacks.
44
52
  * @param {string} filePath - File path to validate
45
53
  * @param {string} baseDir - Expected base directory
46
54
  * @returns {boolean} True if path is safe
47
55
  */
48
56
  function isPathSafe(filePath, baseDir) {
49
- const result = validatePath(filePath, baseDir, { allowSymlinks: true });
57
+ // Security hardening (US-0104): Symlinks disabled to prevent escape attacks
58
+ const result = validatePath(filePath, baseDir, { allowSymlinks: false });
50
59
  return result.ok;
51
60
  }
52
61
 
@@ -76,19 +85,32 @@ function generateAgentList(agentsDir) {
76
85
  continue;
77
86
  }
78
87
 
79
- agents.push({
88
+ // Sanitize agent data to prevent injection attacks
89
+ const rawAgent = {
80
90
  name: frontmatter.name || path.basename(file, '.md'),
81
91
  description: frontmatter.description || '',
82
92
  tools: normalizeTools(frontmatter.tools),
83
93
  model: frontmatter.model || 'haiku',
84
- });
94
+ };
95
+
96
+ const sanitizedAgent = sanitizeAgentData(rawAgent);
97
+
98
+ // Skip if sanitization produced invalid data
99
+ if (!sanitizedAgent.name || sanitizedAgent.name === 'unknown') {
100
+ continue;
101
+ }
102
+
103
+ agents.push(sanitizedAgent);
85
104
  }
86
105
 
87
106
  agents.sort((a, b) => a.name.localeCompare(b.name));
88
107
 
89
- let output = `**AVAILABLE AGENTS (${agents.length} total)**:\n\n`;
108
+ // Sanitize the count value
109
+ const safeCount = sanitize.count(agents.length);
110
+ let output = `**AVAILABLE AGENTS (${safeCount} total)**:\n\n`;
90
111
 
91
112
  agents.forEach((agent, index) => {
113
+ // All values are already sanitized by sanitizeAgentData
92
114
  output += `${index + 1}. **${agent.name}** (model: ${agent.model})\n`;
93
115
  output += ` - **Purpose**: ${agent.description}\n`;
94
116
  output += ` - **Tools**: ${agent.tools.join(', ')}\n`;
@@ -127,11 +149,19 @@ function generateCommandList(commandsDir) {
127
149
  continue;
128
150
  }
129
151
 
130
- commands.push({
152
+ // Sanitize command data to prevent injection attacks
153
+ const rawCommand = {
131
154
  name: cmdName,
132
155
  description: frontmatter.description || '',
133
156
  argumentHint: frontmatter['argument-hint'] || '',
134
- });
157
+ };
158
+
159
+ const sanitizedCommand = sanitizeCommandData(rawCommand);
160
+ if (!sanitizedCommand.name || sanitizedCommand.name === 'unknown') {
161
+ continue;
162
+ }
163
+
164
+ commands.push(sanitizedCommand);
135
165
  }
136
166
 
137
167
  // Scan subdirectories (e.g., session/)
@@ -163,20 +193,31 @@ function generateCommandList(commandsDir) {
163
193
  continue;
164
194
  }
165
195
 
166
- commands.push({
196
+ // Sanitize command data
197
+ const rawCommand = {
167
198
  name: cmdName,
168
199
  description: frontmatter.description || '',
169
200
  argumentHint: frontmatter['argument-hint'] || '',
170
- });
201
+ };
202
+
203
+ const sanitizedCommand = sanitizeCommandData(rawCommand);
204
+ if (!sanitizedCommand.name || sanitizedCommand.name === 'unknown') {
205
+ continue;
206
+ }
207
+
208
+ commands.push(sanitizedCommand);
171
209
  }
172
210
  }
173
211
  }
174
212
 
175
213
  commands.sort((a, b) => a.name.localeCompare(b.name));
176
214
 
177
- let output = `Available commands (${commands.length} total):\n`;
215
+ // Sanitize the count value
216
+ const safeCount = sanitize.count(commands.length);
217
+ let output = `Available commands (${safeCount} total):\n`;
178
218
 
179
219
  commands.forEach(cmd => {
220
+ // All values are already sanitized by sanitizeCommandData
180
221
  const argHint = cmd.argumentHint ? ` ${cmd.argumentHint}` : '';
181
222
  output += `- \`/agileflow:${cmd.name}${argHint}\` - ${cmd.description}\n`;
182
223
  });
@@ -208,16 +249,28 @@ function injectContent(content, context = {}) {
208
249
  counts = getCounts(coreDir);
209
250
  }
210
251
 
252
+ // Validate and sanitize all placeholder values before injection
253
+ const safeCommandCount = validatePlaceholderValue('COMMAND_COUNT', counts.commands).sanitized;
254
+ const safeAgentCount = validatePlaceholderValue('AGENT_COUNT', counts.agents).sanitized;
255
+ const safeSkillCount = validatePlaceholderValue('SKILL_COUNT', counts.skills).sanitized;
256
+ const safeVersion = validatePlaceholderValue('VERSION', version).sanitized;
257
+ const safeDate = validatePlaceholderValue('INSTALL_DATE', new Date()).sanitized;
258
+ const safeAgileflowFolder = validatePlaceholderValue(
259
+ 'agileflow_folder',
260
+ agileflowFolder
261
+ ).sanitized;
262
+
211
263
  // Replace count placeholders (both formats: {{X}} and <!-- {{X}} -->)
212
- result = result.replace(/\{\{COMMAND_COUNT\}\}/g, String(counts.commands));
213
- result = result.replace(/\{\{AGENT_COUNT\}\}/g, String(counts.agents));
214
- result = result.replace(/\{\{SKILL_COUNT\}\}/g, String(counts.skills));
264
+ result = result.replace(/\{\{COMMAND_COUNT\}\}/g, String(safeCommandCount));
265
+ result = result.replace(/\{\{AGENT_COUNT\}\}/g, String(safeAgentCount));
266
+ result = result.replace(/\{\{SKILL_COUNT\}\}/g, String(safeSkillCount));
215
267
 
216
268
  // Replace metadata placeholders
217
- result = result.replace(/\{\{VERSION\}\}/g, version);
218
- result = result.replace(/\{\{INSTALL_DATE\}\}/g, new Date().toISOString().split('T')[0]);
269
+ result = result.replace(/\{\{VERSION\}\}/g, safeVersion);
270
+ result = result.replace(/\{\{INSTALL_DATE\}\}/g, safeDate);
219
271
 
220
272
  // Replace list placeholders (only if core directory available)
273
+ // List generation already includes sanitization via sanitizeAgentData/sanitizeCommandData
221
274
  if (coreDir && fs.existsSync(coreDir)) {
222
275
  if (result.includes('{{AGENT_LIST}}')) {
223
276
  const agentList = generateAgentList(path.join(coreDir, 'agents'));
@@ -232,8 +285,8 @@ function injectContent(content, context = {}) {
232
285
  }
233
286
  }
234
287
 
235
- // Replace folder placeholders
236
- result = result.replace(/\{agileflow_folder\}/g, agileflowFolder);
288
+ // Replace folder placeholders with sanitized values
289
+ result = result.replace(/\{agileflow_folder\}/g, safeAgileflowFolder);
237
290
  result = result.replace(/\{project-root\}/g, '{project-root}'); // Keep as-is for runtime
238
291
 
239
292
  return result;
@@ -4,8 +4,15 @@
4
4
  * Provides specific error types for common IDE setup failures.
5
5
  * These errors carry context about what failed and why,
6
6
  * enabling better error handling and user feedback.
7
+ *
8
+ * Integration with error-codes.js:
9
+ * - All IDE errors now have errorCode, severity, category metadata
10
+ * - Use formatError() from error-codes.js for consistent display
11
+ * - isRecoverable() works with these errors
7
12
  */
8
13
 
14
+ const { ErrorCodes, Severity, Category } = require('../../../lib/error-codes');
15
+
9
16
  /**
10
17
  * Base error class for IDE-related errors.
11
18
  * All IDE errors extend this class.
@@ -15,13 +22,22 @@ class IdeError extends Error {
15
22
  * @param {string} message - Error description
16
23
  * @param {string} ideName - Name of the IDE (e.g., 'Claude Code', 'Cursor')
17
24
  * @param {Object} [context={}] - Additional context about the error
25
+ * @param {string} [errorCode='EUNKNOWN'] - Error code from error-codes.js
18
26
  */
19
- constructor(message, ideName, context = {}) {
27
+ constructor(message, ideName, context = {}, errorCode = 'EUNKNOWN') {
20
28
  super(message);
21
29
  this.name = this.constructor.name;
22
30
  this.ideName = ideName;
23
31
  this.context = context;
24
32
  Error.captureStackTrace(this, this.constructor);
33
+
34
+ // Attach error code metadata from unified error codes
35
+ const codeData = ErrorCodes[errorCode] || ErrorCodes.EUNKNOWN;
36
+ this.errorCode = codeData.code;
37
+ this.severity = codeData.severity;
38
+ this.category = codeData.category;
39
+ this.recoverable = codeData.recoverable;
40
+ this.autoFix = codeData.autoFix || null;
25
41
  }
26
42
 
27
43
  /**
@@ -31,6 +47,16 @@ class IdeError extends Error {
31
47
  getUserMessage() {
32
48
  return `${this.ideName}: ${this.message}`;
33
49
  }
50
+
51
+ /**
52
+ * Get suggested action to fix the error
53
+ * Override in subclasses for specific suggestions
54
+ * @returns {string}
55
+ */
56
+ getSuggestedAction() {
57
+ const codeData = ErrorCodes[this.errorCode] || ErrorCodes.EUNKNOWN;
58
+ return codeData.suggestedFix;
59
+ }
34
60
  }
35
61
 
36
62
  /**
@@ -44,10 +70,12 @@ class IdeConfigNotFoundError extends IdeError {
44
70
  * @param {Object} [context={}] - Additional context
45
71
  */
46
72
  constructor(ideName, configPath, context = {}) {
47
- super(`Configuration directory not found: ${configPath}`, ideName, {
48
- configPath,
49
- ...context,
50
- });
73
+ super(
74
+ `Configuration directory not found: ${configPath}`,
75
+ ideName,
76
+ { configPath, ...context },
77
+ 'ENODIR' // Use unified error code
78
+ );
51
79
  this.configPath = configPath;
52
80
  }
53
81
 
@@ -72,11 +100,23 @@ class CommandInstallationError extends IdeError {
72
100
  * @param {Object} [context={}] - Additional context
73
101
  */
74
102
  constructor(ideName, commandName, reason, context = {}) {
75
- super(`Failed to install command '${commandName}': ${reason}`, ideName, {
76
- commandName,
77
- reason,
78
- ...context,
79
- });
103
+ // Detect appropriate error code from reason
104
+ let errorCode = 'ESTATE';
105
+ if (reason.toLowerCase().includes('permission')) {
106
+ errorCode = 'EACCES';
107
+ } else if (
108
+ reason.toLowerCase().includes('not found') ||
109
+ reason.toLowerCase().includes('no such')
110
+ ) {
111
+ errorCode = 'ENOENT';
112
+ }
113
+
114
+ super(
115
+ `Failed to install command '${commandName}': ${reason}`,
116
+ ideName,
117
+ { commandName, reason, ...context },
118
+ errorCode
119
+ );
80
120
  this.commandName = commandName;
81
121
  this.reason = reason;
82
122
  }
@@ -108,11 +148,12 @@ class FilePermissionError extends IdeError {
108
148
  * @param {Object} [context={}] - Additional context
109
149
  */
110
150
  constructor(ideName, filePath, operation, context = {}) {
111
- super(`Permission denied: cannot ${operation} '${filePath}'`, ideName, {
112
- filePath,
113
- operation,
114
- ...context,
115
- });
151
+ super(
152
+ `Permission denied: cannot ${operation} '${filePath}'`,
153
+ ideName,
154
+ { filePath, operation, ...context },
155
+ 'EACCES' // Use unified error code
156
+ );
116
157
  this.filePath = filePath;
117
158
  this.operation = operation;
118
159
  }
@@ -138,14 +179,23 @@ class ContentInjectionError extends IdeError {
138
179
  * @param {Object} [context={}] - Additional context
139
180
  */
140
181
  constructor(ideName, templateFile, reason, context = {}) {
141
- super(`Content injection failed for '${templateFile}': ${reason}`, ideName, {
142
- templateFile,
143
- reason,
144
- ...context,
145
- });
182
+ super(
183
+ `Content injection failed for '${templateFile}': ${reason}`,
184
+ ideName,
185
+ { templateFile, reason, ...context },
186
+ 'ECONFIG' // Use unified error code - configuration/template issue
187
+ );
146
188
  this.templateFile = templateFile;
147
189
  this.reason = reason;
148
190
  }
191
+
192
+ /**
193
+ * Get suggested action to fix the error
194
+ * @returns {string}
195
+ */
196
+ getSuggestedAction() {
197
+ return `Check the template file '${this.templateFile}' for valid placeholders. Run "npx agileflow doctor --fix" to repair.`;
198
+ }
149
199
  }
150
200
 
151
201
  /**
@@ -160,14 +210,34 @@ class CleanupError extends IdeError {
160
210
  * @param {Object} [context={}] - Additional context
161
211
  */
162
212
  constructor(ideName, targetPath, reason, context = {}) {
163
- super(`Cleanup failed for '${targetPath}': ${reason}`, ideName, {
164
- targetPath,
165
- reason,
166
- ...context,
167
- });
213
+ // Detect appropriate error code from reason
214
+ let errorCode = 'ESTATE';
215
+ if (reason.toLowerCase().includes('lock') || reason.toLowerCase().includes('busy')) {
216
+ errorCode = 'ELOCK';
217
+ } else if (reason.toLowerCase().includes('permission')) {
218
+ errorCode = 'EACCES';
219
+ }
220
+
221
+ super(
222
+ `Cleanup failed for '${targetPath}': ${reason}`,
223
+ ideName,
224
+ { targetPath, reason, ...context },
225
+ errorCode
226
+ );
168
227
  this.targetPath = targetPath;
169
228
  this.reason = reason;
170
229
  }
230
+
231
+ /**
232
+ * Get suggested action to fix the error
233
+ * @returns {string}
234
+ */
235
+ getSuggestedAction() {
236
+ if (this.errorCode === 'ELOCK') {
237
+ return `Close any applications using files in '${this.targetPath}' and try again.`;
238
+ }
239
+ return `Check permissions on '${this.targetPath}' or remove it manually.`;
240
+ }
171
241
  }
172
242
 
173
243
  /**
@@ -181,12 +251,29 @@ class IdeDetectionError extends IdeError {
181
251
  * @param {Object} [context={}] - Additional context
182
252
  */
183
253
  constructor(ideName, reason, context = {}) {
184
- super(`IDE detection failed: ${reason}`, ideName, {
185
- reason,
186
- ...context,
187
- });
254
+ // Detect appropriate error code
255
+ let errorCode = 'ECONFLICT';
256
+ if (
257
+ reason.toLowerCase().includes('not found') ||
258
+ reason.toLowerCase().includes('not installed')
259
+ ) {
260
+ errorCode = 'ENOENT';
261
+ }
262
+
263
+ super(`IDE detection failed: ${reason}`, ideName, { reason, ...context }, errorCode);
188
264
  this.reason = reason;
189
265
  }
266
+
267
+ /**
268
+ * Get suggested action to fix the error
269
+ * @returns {string}
270
+ */
271
+ getSuggestedAction() {
272
+ if (this.errorCode === 'ENOENT') {
273
+ return `Install ${this.ideName} and run it at least once to initialize configuration.`;
274
+ }
275
+ return `Check IDE configuration and resolve any conflicts. Run "npx agileflow doctor" for details.`;
276
+ }
190
277
  }
191
278
 
192
279
  /**
@@ -220,6 +307,52 @@ function isIdeError(error) {
220
307
  return error instanceof IdeError;
221
308
  }
222
309
 
310
+ /**
311
+ * Format an IDE error for display using unified error code format
312
+ * @param {IdeError} error - IDE error to format
313
+ * @param {Object} [options={}] - Format options
314
+ * @param {boolean} [options.includeStack=false] - Include stack trace
315
+ * @param {boolean} [options.includeSuggestion=true] - Include suggested action
316
+ * @returns {string} Formatted error string
317
+ */
318
+ function formatIdeError(error, options = {}) {
319
+ const { includeStack = false, includeSuggestion = true } = options;
320
+
321
+ if (!error) return 'Unknown error';
322
+
323
+ const lines = [];
324
+
325
+ // Main error line with IDE name and error code
326
+ lines.push(`[${error.errorCode}] ${error.ideName}: ${error.message}`);
327
+
328
+ // Severity and category
329
+ lines.push(` Severity: ${error.severity} | Category: ${error.category}`);
330
+
331
+ // Suggested action (IDE-specific takes precedence)
332
+ if (includeSuggestion) {
333
+ const suggestion = error.getSuggestedAction?.() || error.suggestedFix;
334
+ if (suggestion) {
335
+ lines.push(` Fix: ${suggestion}`);
336
+ }
337
+ }
338
+
339
+ // Recoverable status
340
+ lines.push(` Recoverable: ${error.recoverable ? 'Yes' : 'No'}`);
341
+
342
+ // Auto-fix availability
343
+ if (error.autoFix) {
344
+ lines.push(` Auto-fix available: npx agileflow doctor --fix`);
345
+ }
346
+
347
+ // Stack trace
348
+ if (includeStack && error.stack) {
349
+ lines.push('');
350
+ lines.push(error.stack);
351
+ }
352
+
353
+ return lines.join('\n');
354
+ }
355
+
223
356
  module.exports = {
224
357
  IdeError,
225
358
  IdeConfigNotFoundError,
@@ -230,4 +363,5 @@ module.exports = {
230
363
  IdeDetectionError,
231
364
  withPermissionHandling,
232
365
  isIdeError,
366
+ formatIdeError,
233
367
  };