@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 +61 -0
- package/README.md +10 -0
- package/dist/tools/shell-tool.js +23 -13
- package/dist/tools/write-tools.js +8 -5
- package/dist/utils/lib.js +87 -5
- package/package.json +1 -1
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
|
package/dist/tools/shell-tool.js
CHANGED
|
@@ -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
|
-
//
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
190
|
+
invalidPaths.map((p) => ` - ${p}`).join("\n") +
|
|
181
191
|
`\n\nAllowed directories:\n` +
|
|
182
|
-
allowedDirs.map(d => ` - ${d}`).join(
|
|
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(
|
|
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
|
|
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.
|
|
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",
|