@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 +46 -0
- package/README.md +10 -0
- package/dist/tools/filesystem-tools.js +0 -4
- package/dist/tools/write-tools.js +8 -5
- package/dist/utils/lib.js +87 -5
- package/package.json +1 -1
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
|
|
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
|
-
|
|
198
|
-
|
|
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
|
-
|
|
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
|
|
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",
|