agileflow 2.81.0 → 2.82.1

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,337 @@
1
+ /**
2
+ * AgileFlow CLI - Input Validation Utilities
3
+ *
4
+ * Centralized validation patterns and helpers to prevent
5
+ * command injection and invalid input handling.
6
+ */
7
+
8
+ /**
9
+ * Validation patterns for common input types.
10
+ * All patterns use strict whitelisting approach.
11
+ */
12
+ const PATTERNS = {
13
+ // Git branch: alphanumeric, underscores, hyphens, forward slashes
14
+ // Examples: main, feature/US-0001, session-1, my_branch
15
+ branchName: /^[a-zA-Z0-9][a-zA-Z0-9_/-]*$/,
16
+
17
+ // Story ID: US-0001 to US-99999
18
+ storyId: /^US-\d{4,5}$/,
19
+
20
+ // Epic ID: EP-0001 to EP-99999
21
+ epicId: /^EP-\d{4,5}$/,
22
+
23
+ // Feature name: lowercase with hyphens, starts with letter
24
+ // Examples: damage-control, status-line, archival
25
+ featureName: /^[a-z][a-z0-9-]*$/,
26
+
27
+ // Profile name: alphanumeric with underscores/hyphens, starts with letter
28
+ // Examples: default, my-profile, dev_config
29
+ profileName: /^[a-zA-Z][a-zA-Z0-9_-]*$/,
30
+
31
+ // Command name: alphanumeric with hyphens/colons, starts with letter
32
+ // Examples: babysit, story:list, agileflow:configure
33
+ commandName: /^[a-zA-Z][a-zA-Z0-9:-]*$/,
34
+
35
+ // Session nickname: alphanumeric with hyphens/underscores
36
+ // Examples: auth-work, feature_1, main
37
+ sessionNickname: /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/,
38
+
39
+ // Merge strategy: squash or merge
40
+ // Examples: squash, merge
41
+ mergeStrategy: /^(squash|merge)$/,
42
+ };
43
+
44
+ /**
45
+ * Validate a git branch name.
46
+ * @param {string} name - Branch name to validate
47
+ * @returns {boolean} True if valid
48
+ */
49
+ function isValidBranchName(name) {
50
+ if (!name || typeof name !== 'string') return false;
51
+ if (name.length > 255) return false; // Git limit
52
+ if (name.startsWith('-')) return false; // Prevent flag injection
53
+ if (name.includes('..')) return false; // Prevent path traversal
54
+ if (name.endsWith('.lock')) return false; // Reserved
55
+ return PATTERNS.branchName.test(name);
56
+ }
57
+
58
+ /**
59
+ * Validate a story ID (US-XXXX format).
60
+ * @param {string} id - Story ID to validate
61
+ * @returns {boolean} True if valid
62
+ */
63
+ function isValidStoryId(id) {
64
+ if (!id || typeof id !== 'string') return false;
65
+ return PATTERNS.storyId.test(id);
66
+ }
67
+
68
+ /**
69
+ * Validate an epic ID (EP-XXXX format).
70
+ * @param {string} id - Epic ID to validate
71
+ * @returns {boolean} True if valid
72
+ */
73
+ function isValidEpicId(id) {
74
+ if (!id || typeof id !== 'string') return false;
75
+ return PATTERNS.epicId.test(id);
76
+ }
77
+
78
+ /**
79
+ * Validate a feature name.
80
+ * @param {string} name - Feature name to validate
81
+ * @returns {boolean} True if valid
82
+ */
83
+ function isValidFeatureName(name) {
84
+ if (!name || typeof name !== 'string') return false;
85
+ if (name.length > 50) return false;
86
+ return PATTERNS.featureName.test(name);
87
+ }
88
+
89
+ /**
90
+ * Validate a profile name.
91
+ * @param {string} name - Profile name to validate
92
+ * @returns {boolean} True if valid
93
+ */
94
+ function isValidProfileName(name) {
95
+ if (!name || typeof name !== 'string') return false;
96
+ if (name.length > 50) return false;
97
+ return PATTERNS.profileName.test(name);
98
+ }
99
+
100
+ /**
101
+ * Validate a command name.
102
+ * @param {string} name - Command name to validate
103
+ * @returns {boolean} True if valid
104
+ */
105
+ function isValidCommandName(name) {
106
+ if (!name || typeof name !== 'string') return false;
107
+ if (name.length > 100) return false;
108
+ return PATTERNS.commandName.test(name);
109
+ }
110
+
111
+ /**
112
+ * Validate a session nickname.
113
+ * @param {string} name - Nickname to validate
114
+ * @returns {boolean} True if valid
115
+ */
116
+ function isValidSessionNickname(name) {
117
+ if (!name || typeof name !== 'string') return false;
118
+ if (name.length > 50) return false;
119
+ return PATTERNS.sessionNickname.test(name);
120
+ }
121
+
122
+ /**
123
+ * Validate a merge strategy (squash or merge).
124
+ * @param {string} strategy - Strategy to validate
125
+ * @returns {boolean} True if valid
126
+ */
127
+ function isValidMergeStrategy(strategy) {
128
+ if (!strategy || typeof strategy !== 'string') return false;
129
+ return PATTERNS.mergeStrategy.test(strategy);
130
+ }
131
+
132
+ /**
133
+ * Validate that a value is a positive integer within bounds.
134
+ * @param {any} val - Value to validate
135
+ * @param {number} min - Minimum allowed value (inclusive)
136
+ * @param {number} max - Maximum allowed value (inclusive)
137
+ * @returns {boolean} True if valid positive integer in range
138
+ */
139
+ function isPositiveInteger(val, min = 1, max = Number.MAX_SAFE_INTEGER) {
140
+ const num = typeof val === 'string' ? parseInt(val, 10) : val;
141
+ if (!Number.isInteger(num)) return false;
142
+ return num >= min && num <= max;
143
+ }
144
+
145
+ /**
146
+ * Parse and validate an integer with bounds.
147
+ * @param {any} val - Value to parse
148
+ * @param {number} defaultVal - Default if invalid
149
+ * @param {number} min - Minimum allowed value
150
+ * @param {number} max - Maximum allowed value
151
+ * @returns {number} Parsed integer or default
152
+ */
153
+ function parseIntBounded(val, defaultVal, min = 1, max = Number.MAX_SAFE_INTEGER) {
154
+ const num = typeof val === 'string' ? parseInt(val, 10) : val;
155
+ if (!Number.isInteger(num) || num < min || num > max) {
156
+ return defaultVal;
157
+ }
158
+ return num;
159
+ }
160
+
161
+ /**
162
+ * Validate an option key against an allowed whitelist.
163
+ * @param {string} key - Option key to validate
164
+ * @param {string[]} allowedKeys - Array of allowed keys
165
+ * @returns {boolean} True if key is in whitelist
166
+ */
167
+ function isValidOption(key, allowedKeys) {
168
+ if (!key || typeof key !== 'string') return false;
169
+ if (!Array.isArray(allowedKeys)) return false;
170
+ return allowedKeys.includes(key);
171
+ }
172
+
173
+ /**
174
+ * Validate and sanitize CLI arguments for known option types.
175
+ * Returns validated args object or null if critical validation fails.
176
+ *
177
+ * @param {string[]} args - Raw process.argv slice
178
+ * @param {Object} schema - Schema defining expected options
179
+ * @returns {{ ok: boolean, data?: Object, error?: string }}
180
+ *
181
+ * @example
182
+ * const schema = {
183
+ * branch: { type: 'branchName', required: true },
184
+ * max: { type: 'positiveInt', min: 1, max: 100, default: 20 },
185
+ * profile: { type: 'profileName', default: 'default' }
186
+ * };
187
+ * const result = validateArgs(args, schema);
188
+ */
189
+ function validateArgs(args, schema) {
190
+ const result = {};
191
+ const errors = [];
192
+
193
+ // Parse args into key-value pairs
194
+ const parsed = {};
195
+ for (let i = 0; i < args.length; i++) {
196
+ const arg = args[i];
197
+ if (arg.startsWith('--')) {
198
+ const [key, ...valueParts] = arg.slice(2).split('=');
199
+ const value = valueParts.length > 0 ? valueParts.join('=') : args[++i];
200
+ parsed[key] = value;
201
+ }
202
+ }
203
+
204
+ // Validate each schema field
205
+ for (const [field, config] of Object.entries(schema)) {
206
+ const value = parsed[field];
207
+
208
+ // Check required
209
+ if (config.required && (value === undefined || value === null)) {
210
+ errors.push(`Missing required option: --${field}`);
211
+ continue;
212
+ }
213
+
214
+ // Use default if not provided
215
+ if (value === undefined || value === null) {
216
+ if (config.default !== undefined) {
217
+ result[field] = config.default;
218
+ }
219
+ continue;
220
+ }
221
+
222
+ // Validate by type
223
+ switch (config.type) {
224
+ case 'branchName':
225
+ if (!isValidBranchName(value)) {
226
+ errors.push(`Invalid branch name: ${value}`);
227
+ } else {
228
+ result[field] = value;
229
+ }
230
+ break;
231
+
232
+ case 'storyId':
233
+ if (!isValidStoryId(value)) {
234
+ errors.push(`Invalid story ID: ${value} (expected US-XXXX)`);
235
+ } else {
236
+ result[field] = value;
237
+ }
238
+ break;
239
+
240
+ case 'epicId':
241
+ if (!isValidEpicId(value)) {
242
+ errors.push(`Invalid epic ID: ${value} (expected EP-XXXX)`);
243
+ } else {
244
+ result[field] = value;
245
+ }
246
+ break;
247
+
248
+ case 'featureName':
249
+ if (!isValidFeatureName(value)) {
250
+ errors.push(`Invalid feature name: ${value}`);
251
+ } else {
252
+ result[field] = value;
253
+ }
254
+ break;
255
+
256
+ case 'profileName':
257
+ if (!isValidProfileName(value)) {
258
+ errors.push(`Invalid profile name: ${value}`);
259
+ } else {
260
+ result[field] = value;
261
+ }
262
+ break;
263
+
264
+ case 'commandName':
265
+ if (!isValidCommandName(value)) {
266
+ errors.push(`Invalid command name: ${value}`);
267
+ } else {
268
+ result[field] = value;
269
+ }
270
+ break;
271
+
272
+ case 'sessionNickname':
273
+ if (!isValidSessionNickname(value)) {
274
+ errors.push(`Invalid session nickname: ${value}`);
275
+ } else {
276
+ result[field] = value;
277
+ }
278
+ break;
279
+
280
+ case 'positiveInt': {
281
+ const min = config.min || 1;
282
+ const max = config.max || Number.MAX_SAFE_INTEGER;
283
+ if (!isPositiveInteger(value, min, max)) {
284
+ errors.push(`Invalid integer for ${field}: ${value} (expected ${min}-${max})`);
285
+ result[field] = config.default;
286
+ } else {
287
+ result[field] = parseInt(value, 10);
288
+ }
289
+ break;
290
+ }
291
+
292
+ case 'enum':
293
+ if (!config.values.includes(value)) {
294
+ errors.push(
295
+ `Invalid value for ${field}: ${value} (expected: ${config.values.join(', ')})`
296
+ );
297
+ } else {
298
+ result[field] = value;
299
+ }
300
+ break;
301
+
302
+ case 'boolean':
303
+ result[field] = value === 'true' || value === '1' || value === true;
304
+ break;
305
+
306
+ case 'string':
307
+ // Basic string - just store it (caller should validate further if needed)
308
+ result[field] = String(value);
309
+ break;
310
+
311
+ default:
312
+ result[field] = value;
313
+ }
314
+ }
315
+
316
+ if (errors.length > 0) {
317
+ return { ok: false, error: errors.join('; ') };
318
+ }
319
+
320
+ return { ok: true, data: result };
321
+ }
322
+
323
+ module.exports = {
324
+ PATTERNS,
325
+ isValidBranchName,
326
+ isValidStoryId,
327
+ isValidEpicId,
328
+ isValidFeatureName,
329
+ isValidProfileName,
330
+ isValidCommandName,
331
+ isValidSessionNickname,
332
+ isValidMergeStrategy,
333
+ isPositiveInteger,
334
+ parseIntBounded,
335
+ isValidOption,
336
+ validateArgs,
337
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agileflow",
3
- "version": "2.81.0",
3
+ "version": "2.82.1",
4
4
  "description": "AI-driven agile development system for Claude Code, Cursor, Windsurf, and more",
5
5
  "keywords": [
6
6
  "agile",
@@ -32,6 +32,7 @@
32
32
  "tools/",
33
33
  "src/",
34
34
  "scripts/",
35
+ "lib/",
35
36
  "LICENSE",
36
37
  "README.md"
37
38
  ],