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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.89.3] - 2026-01-14
11
+
12
+ ### Added
13
+ - Security hardening with shell injection prevention and secret redaction
14
+
15
+ ## [2.89.2] - 2026-01-14
16
+
17
+ ### Fixed
18
+ - Increase precompact preservation window to 10 minutes
19
+
10
20
  ## [2.89.1] - 2026-01-14
11
21
 
12
22
  ### Fixed
@@ -0,0 +1,463 @@
1
+ /**
2
+ * content-sanitizer.js - Content Security for Dynamic Injection
3
+ *
4
+ * Provides sanitization for dynamic content that gets injected into
5
+ * files during installation. Prevents injection attacks through
6
+ * malicious placeholder values.
7
+ *
8
+ * Security Model:
9
+ * - All dynamic values are validated against expected patterns
10
+ * - Special characters are escaped for Markdown and shell contexts
11
+ * - Agent/command names are restricted to safe character sets
12
+ *
13
+ * Usage:
14
+ * const { sanitize, validatePlaceholderValue } = require('./content-sanitizer');
15
+ *
16
+ * // Sanitize agent name for markdown
17
+ * const safeName = sanitize.name(agentName);
18
+ *
19
+ * // Validate count values
20
+ * const safeCount = sanitize.count(count);
21
+ */
22
+
23
+ /**
24
+ * Patterns for validating dynamic content
25
+ */
26
+ const PATTERNS = {
27
+ // Agent/command names: alphanumeric, hyphens, underscores
28
+ name: /^[a-zA-Z][a-zA-Z0-9_-]*$/,
29
+
30
+ // Description: printable ASCII, no control chars or dangerous sequences
31
+ description: /^[\x20-\x7E\u00A0-\uFFFF]*$/,
32
+
33
+ // Count: non-negative integer
34
+ count: /^\d+$/,
35
+
36
+ // Version: semver-like pattern
37
+ version: /^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.]+)?$/,
38
+
39
+ // Date: ISO date format
40
+ date: /^\d{4}-\d{2}-\d{2}$/,
41
+
42
+ // Folder name: alphanumeric, dots, hyphens, underscores
43
+ folderName: /^[a-zA-Z0-9._-]+$/,
44
+
45
+ // Tool name: alphanumeric with some special chars
46
+ toolName: /^[a-zA-Z][a-zA-Z0-9_*-]*$/,
47
+
48
+ // Model name: simple identifier
49
+ modelName: /^[a-z][a-z0-9-]*$/,
50
+ };
51
+
52
+ /**
53
+ * Maximum lengths for various content types
54
+ */
55
+ const MAX_LENGTHS = {
56
+ name: 64,
57
+ description: 500,
58
+ version: 32,
59
+ folderName: 64,
60
+ toolName: 32,
61
+ modelName: 32,
62
+ agentListEntry: 1000,
63
+ commandListEntry: 500,
64
+ };
65
+
66
+ /**
67
+ * Characters that need escaping in Markdown
68
+ */
69
+ const MARKDOWN_ESCAPE_CHARS = [
70
+ '\\',
71
+ '`',
72
+ '*',
73
+ '_',
74
+ '{',
75
+ '}',
76
+ '[',
77
+ ']',
78
+ '(',
79
+ ')',
80
+ '#',
81
+ '+',
82
+ '-',
83
+ '.',
84
+ '!',
85
+ '|',
86
+ ];
87
+
88
+ /**
89
+ * Characters that need escaping in shell context
90
+ */
91
+ const SHELL_ESCAPE_CHARS = [
92
+ '$',
93
+ '`',
94
+ '\\',
95
+ '"',
96
+ "'",
97
+ '!',
98
+ '&',
99
+ ';',
100
+ '|',
101
+ '>',
102
+ '<',
103
+ '(',
104
+ ')',
105
+ '{',
106
+ '}',
107
+ '[',
108
+ ']',
109
+ '*',
110
+ '?',
111
+ '#',
112
+ '~',
113
+ ];
114
+
115
+ /**
116
+ * Escape special characters for Markdown content
117
+ * @param {string} text - Text to escape
118
+ * @returns {string} Escaped text safe for Markdown
119
+ */
120
+ function escapeMarkdown(text) {
121
+ if (!text || typeof text !== 'string') return '';
122
+
123
+ let escaped = text;
124
+ for (const char of MARKDOWN_ESCAPE_CHARS) {
125
+ escaped = escaped.replace(new RegExp('\\' + char, 'g'), '\\' + char);
126
+ }
127
+ return escaped;
128
+ }
129
+
130
+ /**
131
+ * Escape special characters for shell/command context
132
+ * @param {string} text - Text to escape
133
+ * @returns {string} Escaped text safe for shell
134
+ */
135
+ function escapeShell(text) {
136
+ if (!text || typeof text !== 'string') return '';
137
+
138
+ // Use a single regex replacement to avoid double-escaping backslashes
139
+ return text.replace(/[$`\\"'!&;|><(){}[\]*?#~]/g, char => '\\' + char);
140
+ }
141
+
142
+ /**
143
+ * Remove control characters from text
144
+ * @param {string} text - Text to clean
145
+ * @returns {string} Text without control characters
146
+ */
147
+ function removeControlChars(text) {
148
+ if (!text || typeof text !== 'string') return '';
149
+ // Remove ASCII control chars (0x00-0x1F except tab/newline/carriage return)
150
+ return text.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
151
+ }
152
+
153
+ /**
154
+ * Truncate text to maximum length with ellipsis
155
+ * @param {string} text - Text to truncate
156
+ * @param {number} maxLength - Maximum length
157
+ * @returns {string} Truncated text
158
+ */
159
+ function truncate(text, maxLength) {
160
+ if (!text || typeof text !== 'string') return '';
161
+ if (text.length <= maxLength) return text;
162
+ return text.substring(0, maxLength - 3) + '...';
163
+ }
164
+
165
+ /**
166
+ * Sanitization functions for different content types
167
+ */
168
+ const sanitize = {
169
+ /**
170
+ * Sanitize a name (agent name, command name, etc.)
171
+ * @param {string} name - Name to sanitize
172
+ * @param {Object} [options={}] - Options
173
+ * @param {boolean} [options.strict=true] - Strict validation (returns empty on invalid)
174
+ * @returns {string} Sanitized name
175
+ */
176
+ name(name, options = {}) {
177
+ const { strict = true } = options;
178
+
179
+ if (!name || typeof name !== 'string') return '';
180
+
181
+ // Clean the name
182
+ let cleaned = removeControlChars(name).trim();
183
+ cleaned = truncate(cleaned, MAX_LENGTHS.name);
184
+
185
+ // Strict mode: validate against pattern
186
+ if (strict) {
187
+ if (!PATTERNS.name.test(cleaned)) {
188
+ return '';
189
+ }
190
+ } else {
191
+ // Permissive mode: just remove dangerous chars
192
+ cleaned = cleaned.replace(/[^a-zA-Z0-9_-]/g, '-');
193
+ // Ensure starts with letter
194
+ if (!/^[a-zA-Z]/.test(cleaned)) {
195
+ cleaned = 'x-' + cleaned;
196
+ }
197
+ }
198
+
199
+ return cleaned;
200
+ },
201
+
202
+ /**
203
+ * Sanitize a description
204
+ * @param {string} description - Description to sanitize
205
+ * @param {Object} [options={}] - Options
206
+ * @param {boolean} [options.escapeMarkdown=true] - Escape markdown chars
207
+ * @returns {string} Sanitized description
208
+ */
209
+ description(description, options = {}) {
210
+ const { escapeMarkdown: shouldEscape = true } = options;
211
+
212
+ if (!description || typeof description !== 'string') return '';
213
+
214
+ let cleaned = removeControlChars(description).trim();
215
+ cleaned = truncate(cleaned, MAX_LENGTHS.description);
216
+
217
+ if (shouldEscape) {
218
+ cleaned = escapeMarkdown(cleaned);
219
+ }
220
+
221
+ return cleaned;
222
+ },
223
+
224
+ /**
225
+ * Sanitize a count value
226
+ * @param {number|string} count - Count to sanitize
227
+ * @returns {number} Sanitized count (0 if invalid)
228
+ */
229
+ count(count) {
230
+ const num = typeof count === 'string' ? parseInt(count, 10) : count;
231
+ if (!Number.isFinite(num) || num < 0) return 0;
232
+ return Math.floor(num);
233
+ },
234
+
235
+ /**
236
+ * Sanitize a version string
237
+ * @param {string} version - Version to sanitize
238
+ * @returns {string} Sanitized version or 'unknown'
239
+ */
240
+ version(version) {
241
+ if (!version || typeof version !== 'string') return 'unknown';
242
+
243
+ const cleaned = removeControlChars(version).trim();
244
+ if (!PATTERNS.version.test(cleaned)) {
245
+ return 'unknown';
246
+ }
247
+
248
+ return truncate(cleaned, MAX_LENGTHS.version);
249
+ },
250
+
251
+ /**
252
+ * Sanitize a date string (ISO format)
253
+ * @param {string|Date} date - Date to sanitize
254
+ * @returns {string} Sanitized date in YYYY-MM-DD format
255
+ */
256
+ date(date) {
257
+ if (date instanceof Date) {
258
+ return date.toISOString().split('T')[0];
259
+ }
260
+
261
+ if (!date || typeof date !== 'string') {
262
+ return new Date().toISOString().split('T')[0];
263
+ }
264
+
265
+ const cleaned = removeControlChars(date).trim();
266
+ if (!PATTERNS.date.test(cleaned)) {
267
+ return new Date().toISOString().split('T')[0];
268
+ }
269
+
270
+ return cleaned;
271
+ },
272
+
273
+ /**
274
+ * Sanitize a folder name
275
+ * @param {string} name - Folder name to sanitize
276
+ * @param {string} [defaultName='.agileflow'] - Default if invalid
277
+ * @returns {string} Sanitized folder name
278
+ */
279
+ folderName(name, defaultName = '.agileflow') {
280
+ if (!name || typeof name !== 'string') return defaultName;
281
+
282
+ const cleaned = removeControlChars(name).trim();
283
+ if (!PATTERNS.folderName.test(cleaned)) {
284
+ return defaultName;
285
+ }
286
+
287
+ return truncate(cleaned, MAX_LENGTHS.folderName);
288
+ },
289
+
290
+ /**
291
+ * Sanitize a tool name for agent tools list
292
+ * @param {string} tool - Tool name to sanitize
293
+ * @returns {string} Sanitized tool name
294
+ */
295
+ toolName(tool) {
296
+ if (!tool || typeof tool !== 'string') return '';
297
+
298
+ const cleaned = removeControlChars(tool).trim();
299
+ if (!PATTERNS.toolName.test(cleaned)) {
300
+ return '';
301
+ }
302
+
303
+ return truncate(cleaned, MAX_LENGTHS.toolName);
304
+ },
305
+
306
+ /**
307
+ * Sanitize a model name
308
+ * @param {string} model - Model name to sanitize
309
+ * @param {string} [defaultModel='haiku'] - Default if invalid
310
+ * @returns {string} Sanitized model name
311
+ */
312
+ modelName(model, defaultModel = 'haiku') {
313
+ if (!model || typeof model !== 'string') return defaultModel;
314
+
315
+ const cleaned = removeControlChars(model).trim().toLowerCase();
316
+ if (!PATTERNS.modelName.test(cleaned)) {
317
+ return defaultModel;
318
+ }
319
+
320
+ return truncate(cleaned, MAX_LENGTHS.modelName);
321
+ },
322
+
323
+ /**
324
+ * Sanitize an array of tool names
325
+ * @param {string[]} tools - Tool names to sanitize
326
+ * @returns {string[]} Sanitized tool names (empty entries removed)
327
+ */
328
+ toolsList(tools) {
329
+ if (!Array.isArray(tools)) return [];
330
+
331
+ return tools.map(t => sanitize.toolName(t)).filter(Boolean);
332
+ },
333
+ };
334
+
335
+ /**
336
+ * Validate a placeholder value against expected type
337
+ * @param {string} placeholder - Placeholder name (e.g., 'COMMAND_COUNT')
338
+ * @param {any} value - Value to validate
339
+ * @returns {{ valid: boolean, sanitized?: any, error?: string }}
340
+ */
341
+ function validatePlaceholderValue(placeholder, value) {
342
+ switch (placeholder) {
343
+ case 'COMMAND_COUNT':
344
+ case 'AGENT_COUNT':
345
+ case 'SKILL_COUNT': {
346
+ const sanitized = sanitize.count(value);
347
+ return { valid: true, sanitized };
348
+ }
349
+
350
+ case 'VERSION': {
351
+ const sanitized = sanitize.version(value);
352
+ if (sanitized === 'unknown' && value !== 'unknown') {
353
+ return { valid: false, error: `Invalid version format: ${value}` };
354
+ }
355
+ return { valid: true, sanitized };
356
+ }
357
+
358
+ case 'INSTALL_DATE': {
359
+ const sanitized = sanitize.date(value);
360
+ return { valid: true, sanitized };
361
+ }
362
+
363
+ case 'agileflow_folder': {
364
+ const sanitized = sanitize.folderName(value);
365
+ if (sanitized !== value) {
366
+ return { valid: false, error: `Invalid folder name: ${value}` };
367
+ }
368
+ return { valid: true, sanitized };
369
+ }
370
+
371
+ default:
372
+ return { valid: true, sanitized: value };
373
+ }
374
+ }
375
+
376
+ /**
377
+ * Sanitize agent data for list generation
378
+ * @param {Object} agent - Agent data from frontmatter
379
+ * @returns {Object} Sanitized agent data
380
+ */
381
+ function sanitizeAgentData(agent) {
382
+ return {
383
+ name: sanitize.name(agent.name, { strict: false }) || 'unknown',
384
+ description: sanitize.description(agent.description || ''),
385
+ tools: sanitize.toolsList(agent.tools || []),
386
+ model: sanitize.modelName(agent.model),
387
+ };
388
+ }
389
+
390
+ /**
391
+ * Sanitize command data for list generation
392
+ * @param {Object} command - Command data from frontmatter
393
+ * @returns {Object} Sanitized command data
394
+ */
395
+ function sanitizeCommandData(command) {
396
+ return {
397
+ name: sanitize.name(command.name, { strict: false }) || 'unknown',
398
+ description: sanitize.description(command.description || ''),
399
+ argumentHint: sanitize.description(command.argumentHint || '', { escapeMarkdown: true }),
400
+ };
401
+ }
402
+
403
+ /**
404
+ * Check if content appears to contain injection attempts
405
+ * @param {string} content - Content to check
406
+ * @returns {{ safe: boolean, reason?: string }}
407
+ */
408
+ function detectInjectionAttempt(content) {
409
+ if (!content || typeof content !== 'string') {
410
+ return { safe: true };
411
+ }
412
+
413
+ // Check for shell injection patterns
414
+ const shellInjectionPatterns = [
415
+ /\$\(/, // Command substitution
416
+ /`[^`]+`/, // Backtick execution
417
+ /;\s*rm\s/i, // rm command
418
+ /;\s*dd\s/i, // dd command
419
+ /\|\s*sh\b/i, // Piping to shell
420
+ />\s*\/etc\//i, // Writing to /etc
421
+ /\/dev\/null/i, // /dev/null (suspicious in content)
422
+ ];
423
+
424
+ for (const pattern of shellInjectionPatterns) {
425
+ if (pattern.test(content)) {
426
+ return { safe: false, reason: `Suspicious pattern detected: ${pattern}` };
427
+ }
428
+ }
429
+
430
+ // Check for markdown injection that could break document structure
431
+ const markdownInjectionPatterns = [
432
+ /^#+ /m, // Unexpected headers (could break structure)
433
+ /\[.*\]\(javascript:/i, // JavaScript URLs
434
+ /\[.*\]\(data:/i, // Data URLs
435
+ ];
436
+
437
+ for (const pattern of markdownInjectionPatterns) {
438
+ if (pattern.test(content)) {
439
+ return { safe: false, reason: `Markdown injection pattern detected: ${pattern}` };
440
+ }
441
+ }
442
+
443
+ return { safe: true };
444
+ }
445
+
446
+ module.exports = {
447
+ // Patterns and constants
448
+ PATTERNS,
449
+ MAX_LENGTHS,
450
+
451
+ // Core sanitization functions
452
+ sanitize,
453
+ escapeMarkdown,
454
+ escapeShell,
455
+ removeControlChars,
456
+ truncate,
457
+
458
+ // Validation
459
+ validatePlaceholderValue,
460
+ sanitizeAgentData,
461
+ sanitizeCommandData,
462
+ detectInjectionAttempt,
463
+ };