@n0zer0d4y/vulcan-file-ops 1.1.6 → 1.2.0

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
@@ -5,6 +5,67 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.2.0] - 2025-12-07
9
+
10
+ ### Added
11
+
12
+ - Automatic parent directory creation for write tools:
13
+ - `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
14
+ - `write_multiple_files` now validates all requested paths and automatically creates any missing parent directories within approved directories before performing concurrent writes
15
+ - This removes the need for the model to call `make_directory` explicitly before writing into new subdirectories, reducing round trips and tool calls
16
+ - New `ValidatePathOptions` support for `validatePath` with `createParentIfMissing` flag:
17
+ - Optional flag that instructs `validatePath` to create missing parent directories, subject to strict allowed-directory and symlink safety checks
18
+ - Designed specifically for write operations; read/search tools continue to validate paths without side effects
19
+ - Comprehensive test coverage for the new behavior:
20
+ - Unit tests for `validatePath` covering:
21
+ - Single-level and multi-level directory creation within allowed roots
22
+ - Rejection of paths outside allowed directories even when `createParentIfMissing` is true
23
+ - Handling of race conditions where directories are created between checks
24
+ - Defensive behavior when an expected directory segment is actually a file
25
+ - Symlink interactions to ensure created directories do not escape approved roots
26
+ - Integration tests for:
27
+ - `write_file` writing into deep, initially non-existent directory trees
28
+ - `write_multiple_files` writing multiple files into separate, initially non-existent directory trees
29
+ - Ensuring multi-file writes still fail cleanly when any path is outside approved directories
30
+
31
+ ### Changed
32
+
33
+ - `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
34
+ - Internal path-validation logic refactored to centralize secure directory creation semantics in `validatePath`, ensuring:
35
+ - Normalization and allowed-directory checks are performed before any directory creation
36
+ - Each created directory is validated with `realpath` to stay inside allowed roots
37
+ - Non-directory collisions and unsafe symlink targets are rejected with clear error messages
38
+
39
+ ## [1.1.7] - 2025-11-18
40
+
41
+ ### Security
42
+
43
+ - **CRITICAL**: Fixed command approval bypass vulnerability in `execute_shell` tool
44
+ - Previously, unapproved commands could execute if they didn't match dangerous patterns and `requiresApproval` was not set
45
+ - Now enforces strict whitelist: ALL commands must be in `--approved-commands` list to execute
46
+ - Affected commands that were incorrectly allowed: `dir`, `whoami`, `ipconfig`, `type`, `copy`, `move`, `ren`, `del`, `mkdir`, `rmdir`
47
+ - Added defense-in-depth: dangerous patterns now checked even on approved commands
48
+ - Enhanced error messages showing approved vs unapproved commands with helpful guidance
49
+
50
+ ### Added
51
+
52
+ - 19 comprehensive security tests for strict command whitelist enforcement
53
+ - Tests for unapproved non-dangerous commands (whoami, hostname)
54
+ - Tests for Windows-specific commands (dir, type, copy, move, ren, del, mkdir, rmdir, ipconfig)
55
+ - Regression tests ensuring all vulnerability examples are blocked
56
+ - Defense-in-depth tests for dangerous patterns on approved commands
57
+
58
+ ### Changed
59
+
60
+ - Updated 9 existing shell-tool tests to reflect new strict approval logic
61
+ - Updated error messages from "Command requires approval" to "Command not in approved list"
62
+ - Shell command path validation tests now include all necessary commands in approved list
63
+
64
+ ### Fixed
65
+
66
+ - `execute_shell` tool now properly blocks ALL unapproved commands regardless of `requiresApproval` parameter
67
+ - Closed security bypass where unapproved commands executed with default `requiresApproval=false`
68
+
8
69
  ## [1.1.6] - 2025-11-16
9
70
 
10
71
  ### Fixed
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
@@ -114,16 +114,26 @@ export async function handleShellTool(name, args) {
114
114
  // Extract root commands for approval checking
115
115
  const rootCommands = extractRootCommands(validatedArgs.command);
116
116
  const allApproved = rootCommands.every((cmd) => approvedCommands.has(cmd) || alwaysApprovedCommands.has(cmd));
117
- // Check for dangerous patterns
118
- const isDangerous = isDangerousCommand(validatedArgs.command);
119
- if (!allApproved && (validatedArgs.requiresApproval || isDangerous)) {
117
+ // SECURITY FIX: Block ALL unapproved commands immediately
118
+ // Previously, commands were only blocked if (!allApproved && (requiresApproval || isDangerous))
119
+ // This allowed unapproved non-dangerous commands to execute by default
120
+ if (!allApproved) {
120
121
  const unapprovedCommands = rootCommands.filter((cmd) => !approvedCommands.has(cmd) && !alwaysApprovedCommands.has(cmd));
121
- throw new Error(`Command requires approval. Unapproved commands: ${unapprovedCommands.join(", ")}\n` +
122
+ const approvedList = Array.from(approvedCommands).join(", ");
123
+ throw new Error(`Access denied: Command not in approved list.\n` +
124
+ `Unapproved commands: ${unapprovedCommands.join(", ")}\n` +
125
+ `Command: ${validatedArgs.command}\n\n` +
126
+ `Approved commands: ${approvedList || "(none configured)"}\n\n` +
127
+ `To execute this command, add it to --approved-commands configuration.`);
128
+ }
129
+ // SECURITY: Check dangerous patterns even for approved commands
130
+ // This provides defense-in-depth against accidentally approving dangerous commands
131
+ const isDangerous = isDangerousCommand(validatedArgs.command);
132
+ if (isDangerous && !validatedArgs.requiresApproval) {
133
+ throw new Error(`⚠️ Dangerous command pattern detected.\n` +
122
134
  `Command: ${validatedArgs.command}\n` +
123
- `${isDangerous
124
- ? "⚠️ Warning: This command matches dangerous patterns\n"
125
- : ""}` +
126
- `To approve, add these commands to --approved-commands or .env configuration.`);
135
+ `This command requires explicit approval.\n` +
136
+ `Set requiresApproval: true in the command arguments to proceed.`);
127
137
  }
128
138
  // SECURITY FIX: Validate working directory ALWAYS (not just if provided)
129
139
  // This prevents bypass via process.cwd() when workdir is omitted
@@ -150,7 +160,7 @@ export async function handleShellTool(name, args) {
150
160
  `Error: ${error instanceof Error ? error.message : String(error)}\n` +
151
161
  `\n` +
152
162
  `Allowed directories:\n` +
153
- allowedDirs.map(d => ` - ${d}`).join('\n') +
163
+ allowedDirs.map((d) => ` - ${d}`).join("\n") +
154
164
  `\n\n` +
155
165
  `To execute commands in this directory:\n` +
156
166
  ` 1. Register the directory using register_directory tool, OR\n` +
@@ -165,7 +175,7 @@ export async function handleShellTool(name, args) {
165
175
  if (allowedDirs.length === 0) {
166
176
  throw new Error(`Access denied: Command contains paths but no allowed directories are configured.\n` +
167
177
  `Extracted paths:\n` +
168
- extractedPaths.map(p => ` - ${p}`).join('\n') +
178
+ extractedPaths.map((p) => ` - ${p}`).join("\n") +
169
179
  `\n\nPlease configure allowed directories using --approved-folders or register_directory tool.`);
170
180
  }
171
181
  // Validate each extracted path
@@ -177,9 +187,9 @@ export async function handleShellTool(name, args) {
177
187
  }
178
188
  if (invalidPaths.length > 0) {
179
189
  throw new Error(`Access denied: Command contains paths outside allowed directories:\n` +
180
- invalidPaths.map(p => ` - ${p}`).join('\n') +
190
+ invalidPaths.map((p) => ` - ${p}`).join("\n") +
181
191
  `\n\nAllowed directories:\n` +
182
- allowedDirs.map(d => ` - ${d}`).join('\n') +
192
+ allowedDirs.map((d) => ` - ${d}`).join("\n") +
183
193
  `\n\nTo access these paths, register their parent directories using register_directory tool.`);
184
194
  }
185
195
  }
@@ -187,7 +197,7 @@ export async function handleShellTool(name, args) {
187
197
  catch (error) {
188
198
  // If path extraction fails, be conservative and block
189
199
  // (better to block than allow potentially unsafe commands)
190
- if (error instanceof Error && error.message.includes('Access denied')) {
200
+ if (error instanceof Error && error.message.includes("Access denied")) {
191
201
  throw error;
192
202
  }
193
203
  // For extraction errors, block the command to be safe
@@ -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.6",
3
+ "version": "1.2.0",
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",