@n0zer0d4y/vulcan-file-ops 1.1.7 → 1.2.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.
package/CHANGELOG.md CHANGED
@@ -2,9 +2,55 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+
5
6
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
7
+
6
8
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
9
 
10
+
11
+
12
+ ## [1.2.1] - 2025-12-07
13
+
14
+ ### Changed
15
+
16
+ - Strengthened TypeScript types in `filesystem-tools` to eliminate implicit any usage in internal callbacks
17
+ - Added explicit string typing for directory exclude pattern matching
18
+ - Explicitly typed file operation inputs and indices in `file_operations` handling
19
+ - Clarified intermediate result typing for file operation workflows without altering runtime behavior
20
+ - Improved overall type clarity for directory listing and file operations, preparing the codebase for stricter TypeScript configurations
21
+
22
+ ## [1.2.0] - 2025-12-07
23
+
24
+
25
+ ### Added
26
+
27
+ - Automatic parent directory creation for write tools:
28
+ - `write_file` now detects when the target file's parent directory does not exist and, if the path is within approved directories, creates the full directory chain before writing
29
+ - `write_multiple_files` now validates all requested paths and automatically creates any missing parent directories within approved directories before performing concurrent writes
30
+ - This removes the need for the model to call `make_directory` explicitly before writing into new subdirectories, reducing round trips and tool calls
31
+ - New `ValidatePathOptions` support for `validatePath` with `createParentIfMissing` flag:
32
+ - Optional flag that instructs `validatePath` to create missing parent directories, subject to strict allowed-directory and symlink safety checks
33
+ - Designed specifically for write operations; read/search tools continue to validate paths without side effects
34
+ - Comprehensive test coverage for the new behavior:
35
+ - Unit tests for `validatePath` covering:
36
+ - Single-level and multi-level directory creation within allowed roots
37
+ - Rejection of paths outside allowed directories even when `createParentIfMissing` is true
38
+ - Handling of race conditions where directories are created between checks
39
+ - Defensive behavior when an expected directory segment is actually a file
40
+ - Symlink interactions to ensure created directories do not escape approved roots
41
+ - Integration tests for:
42
+ - `write_file` writing into deep, initially non-existent directory trees
43
+ - `write_multiple_files` writing multiple files into separate, initially non-existent directory trees
44
+ - Ensuring multi-file writes still fail cleanly when any path is outside approved directories
45
+
46
+ ### Changed
47
+
48
+ - `handleWriteTool` implementation for `write_file` and `write_multiple_files` now opts into `validatePath(path, { createParentIfMissing: true })` for write operations only, preserving existing behavior for all other tools
49
+ - Internal path-validation logic refactored to centralize secure directory creation semantics in `validatePath`, ensuring:
50
+ - Normalization and allowed-directory checks are performed before any directory creation
51
+ - Each created directory is validated with `realpath` to stay inside allowed roots
52
+ - Non-directory collisions and unsafe symlink targets are rejected with clear error messages
53
+
8
54
  ## [1.1.7] - 2025-11-18
9
55
 
10
56
  ### Security
package/README.md CHANGED
@@ -496,6 +496,11 @@ Create or replace file content
496
496
 
497
497
  **Note:** This tool is limited to single-file operations only. **RECOMMENDED:** Use `write_multiple_files` instead, which supports both single and batch file operations for greater flexibility.
498
498
 
499
+ **Automatic directory creation:**
500
+
501
+ - If the target file's parent directory does not exist but is inside your configured approved folders, the server will automatically create the required directory structure before writing the file
502
+ - If the path is outside approved folders, the operation fails with a clear error and no directories are created
503
+
499
504
  **Input:**
500
505
 
501
506
  - `path` (string): File path
@@ -507,6 +512,11 @@ Create or replace file content
507
512
 
508
513
  Create or replace multiple files concurrently
509
514
 
515
+ **Automatic directory creation:**
516
+
517
+ - For each requested file, if the parent directory does not exist but is inside your configured approved folders, the server will automatically create the required directory structure before writing
518
+ - Paths outside approved folders are rejected during validation and no directories are created; the operation fails with a detailed list of invalid paths
519
+
510
520
  **Input:**
511
521
 
512
522
  - `files` (array): List of file objects with path and content
@@ -158,9 +158,6 @@ async function collectFileEntry(entryPath, dirent) {
158
158
  };
159
159
  }
160
160
  }
161
- /**
162
- * Helper: Filter entries and collect metadata
163
- */
164
161
  async function filterAndCollectEntries(rawEntries, basePath, args) {
165
162
  let excludedByPatterns = 0;
166
163
  let excludedByIgnoreRules = 0;
@@ -171,7 +168,6 @@ async function filterAndCollectEntries(rawEntries, basePath, args) {
171
168
  excludedByIgnoreRules++;
172
169
  continue;
173
170
  }
174
- // Check user exclude patterns
175
171
  if (args.excludePatterns && args.excludePatterns.length > 0) {
176
172
  const shouldExclude = args.excludePatterns.some((pattern) => {
177
173
  return minimatch(dirent.name, pattern, { dot: true });
@@ -77,8 +77,7 @@ async function processFileEditRequest(request, failOnAmbiguous = true) {
77
77
  const validPath = await validatePath(request.path);
78
78
  const result = await applyFileEdits(validPath, request.edits, request.dryRun || false, request.matchingStrategy || "auto", request.failOnAmbiguous !== undefined
79
79
  ? request.failOnAmbiguous
80
- : failOnAmbiguous, true // Return metadata
81
- );
80
+ : failOnAmbiguous, true);
82
81
  if (typeof result === "string") {
83
82
  throw new Error("Expected metadata but got string result");
84
83
  }
@@ -328,7 +327,9 @@ export async function handleWriteTool(name, args) {
328
327
  // 3. Symlink resolution and target validation (fs.realpath)
329
328
  // 4. Parent directory validation for new files
330
329
  // Prevents: CWE-23 (Path Traversal), CVE-2025-54794, CVE-2025-53109, CVE-2025-53110
331
- const validPath = await validatePath(parsed.data.path);
330
+ const validPath = await validatePath(parsed.data.path, {
331
+ createParentIfMissing: true,
332
+ });
332
333
  await writeFileBasedOnExtension(validPath, parsed.data.content);
333
334
  return {
334
335
  content: [
@@ -382,10 +383,12 @@ export async function handleWriteTool(name, args) {
382
383
  if (!parsed.success) {
383
384
  throw new Error(`Invalid arguments for write_multiple_files: ${parsed.error}`);
384
385
  }
385
- // Validate all paths before any writing
386
+ // Validate all paths before any writing, auto-creating parent directories
386
387
  const validationPromises = parsed.data.files.map(async (file) => {
387
388
  try {
388
- const validPath = await validatePath(file.path);
389
+ const validPath = await validatePath(file.path, {
390
+ createParentIfMissing: true,
391
+ });
389
392
  return {
390
393
  path: file.path,
391
394
  validPath,
package/dist/utils/lib.js CHANGED
@@ -194,8 +194,81 @@ export function createUnifiedDiff(originalContent, newContent, filepath = "file"
194
194
  const normalizedNew = normalizeLineEndings(newContent);
195
195
  return createTwoFilesPatch(filepath, filepath, normalizedOriginal, normalizedNew, "original", "modified");
196
196
  }
197
- // Security & Validation Functions
198
- export async function validatePath(requestedPath) {
197
+ async function ensureParentDirectoryExists(absolutePath) {
198
+ const parentDir = path.dirname(absolutePath);
199
+ // Fast path: if parentDir already exists, let the existing logic handle it
200
+ try {
201
+ const realParentPath = await fs.realpath(parentDir);
202
+ const normalizedParent = normalizePath(realParentPath);
203
+ if (!isPathWithinAllowedDirectories(normalizedParent, allowedDirectories)) {
204
+ throw new Error(`Access denied - parent directory outside allowed directories: ${realParentPath} not in ${allowedDirectories.join(", ")}`);
205
+ }
206
+ return;
207
+ }
208
+ catch (error) {
209
+ if (error.code !== "ENOENT") {
210
+ // If it's not a "does not exist" error, rethrow and let validatePath handle it
211
+ throw error;
212
+ }
213
+ }
214
+ // Parent (or some ancestor) does not exist - we may need to create it.
215
+ // Security: We only create directories if the final requested path is
216
+ // within allowedDirectories (checked in validatePath before calling this),
217
+ // and we validate each created directory as we go.
218
+ const segments = [];
219
+ let current = parentDir;
220
+ // Walk up until we find an existing ancestor or hit filesystem root
221
+ // Note: We don't trust dirname("/") to progress forever; stop when stable.
222
+ while (true) {
223
+ segments.push(current);
224
+ const parent = path.dirname(current);
225
+ if (parent === current) {
226
+ break;
227
+ }
228
+ try {
229
+ await fs.lstat(current);
230
+ break; // Found an existing path
231
+ }
232
+ catch (error) {
233
+ if (error.code === "ENOENT") {
234
+ current = parent;
235
+ continue;
236
+ }
237
+ throw error;
238
+ }
239
+ }
240
+ // We collected segments from leaf up to the first existing ancestor/root.
241
+ // Create them from top-most missing down to the immediate parentDir.
242
+ for (let i = segments.length - 1; i >= 0; i--) {
243
+ const dir = segments[i];
244
+ // Normalize and check allowedDirectories before creating
245
+ const normalizedDir = normalizePath(dir);
246
+ if (!isPathWithinAllowedDirectories(normalizedDir, allowedDirectories)) {
247
+ throw new Error(`Access denied - cannot create directory outside allowed directories: ${dir} not in ${allowedDirectories.join(", ")}`);
248
+ }
249
+ try {
250
+ await fs.mkdir(dir);
251
+ }
252
+ catch (error) {
253
+ if (error.code === "EEXIST") {
254
+ // Something already exists at this path; verify it's a directory
255
+ const stats = await fs.lstat(dir);
256
+ if (!stats.isDirectory()) {
257
+ throw new Error(`Cannot create directory; path exists and is not a directory: ${dir}`);
258
+ }
259
+ continue;
260
+ }
261
+ throw error;
262
+ }
263
+ // After creation, resolve real path and ensure it is still within allowed directories
264
+ const realCreatedPath = await fs.realpath(dir);
265
+ const normalizedCreated = normalizePath(realCreatedPath);
266
+ if (!isPathWithinAllowedDirectories(normalizedCreated, allowedDirectories)) {
267
+ throw new Error(`Access denied - created directory symlink target outside allowed directories: ${realCreatedPath} not in ${allowedDirectories.join(", ")}`);
268
+ }
269
+ }
270
+ }
271
+ export async function validatePath(requestedPath, options) {
199
272
  const expandedPath = expandHome(requestedPath);
200
273
  const absolute = path.isAbsolute(expandedPath)
201
274
  ? path.resolve(expandedPath)
@@ -206,6 +279,7 @@ export async function validatePath(requestedPath) {
206
279
  if (!isAllowed) {
207
280
  throw new Error(`Access denied - path outside allowed directories: ${absolute} not in ${allowedDirectories.join(", ")}`);
208
281
  }
282
+ const createParentIfMissing = options?.createParentIfMissing === true;
209
283
  // Security: Handle symlinks by checking their real path to prevent symlink attacks
210
284
  // This prevents attackers from creating symlinks that point outside allowed directories
211
285
  try {
@@ -229,8 +303,16 @@ export async function validatePath(requestedPath) {
229
303
  }
230
304
  return absolute;
231
305
  }
232
- catch {
233
- throw new Error(`Parent directory does not exist: ${parentDir}`);
306
+ catch (parentError) {
307
+ if (!createParentIfMissing) {
308
+ throw new Error(`Parent directory does not exist: ${parentDir}`);
309
+ }
310
+ // Attempt to securely create the missing parent directory chain
311
+ await ensureParentDirectoryExists(absolute);
312
+ // After creation, return the absolute path for the new file. We intentionally
313
+ // do not call fs.realpath on the file itself here, because it still may not
314
+ // exist yet (for new files).
315
+ return absolute;
234
316
  }
235
317
  }
236
318
  throw error;
@@ -564,7 +646,7 @@ export async function applyFileEdits(filePath, edits, dryRun = false, matchingSt
564
646
  if (returnMetadata) {
565
647
  return {
566
648
  diff: formattedDiff,
567
- metadata: editResults
649
+ metadata: editResults,
568
650
  };
569
651
  }
570
652
  return formattedDiff;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@n0zer0d4y/vulcan-file-ops",
3
- "version": "1.1.7",
3
+ "version": "1.2.1",
4
4
  "mcpName": "io.github.n0zer0d4y/vulcan-file-ops",
5
5
  "description": "MCP server for AI assistants: read, write, edit, and manage files securely on local filesystem.",
6
6
  "license": "MIT",