@n0zer0d4y/vulcan-file-ops 1.2.13 → 1.3.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 +99 -27
- package/README.md +153 -118
- package/dist/cli.js +16 -0
- package/dist/server/index.js +150 -39
- package/dist/tools/filesystem-tools.js +222 -39
- package/dist/tools/read-tools.js +89 -16
- package/dist/tools/shell-tool.js +48 -62
- package/dist/tools/write-tools.js +19 -17
- package/dist/types/index.js +6 -3
- package/dist/utils/command-path-extraction.js +169 -174
- package/dist/utils/command-validation.js +62 -10
- package/dist/utils/document-parser.js +43 -5
- package/dist/utils/html-image-sanitizer.js +478 -0
- package/dist/utils/html-to-document.js +75 -11
- package/dist/utils/lib.js +357 -80
- package/dist/utils/limits.js +47 -0
- package/dist/utils/regex-worker.js +262 -0
- package/dist/utils/shell-parser.js +225 -0
- package/dist/utils/zip-guard.js +266 -0
- package/package.json +11 -8
package/dist/server/index.js
CHANGED
|
@@ -16,7 +16,7 @@ if (_isMCP) {
|
|
|
16
16
|
}
|
|
17
17
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
18
18
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
19
|
-
import { CallToolRequestSchema, ListToolsRequestSchema,
|
|
19
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, PingRequestSchema, RootsListChangedNotificationSchema, ErrorCode, McpError, } from "@modelcontextprotocol/sdk/types.js";
|
|
20
20
|
import fs from "fs/promises";
|
|
21
21
|
import { readFileSync } from "fs";
|
|
22
22
|
import path from "path";
|
|
@@ -34,16 +34,19 @@ const VERSION = packageJson.version;
|
|
|
34
34
|
// Import tool handlers
|
|
35
35
|
import { getReadTools } from "../tools/read-tools.js";
|
|
36
36
|
import { getWriteTools } from "../tools/write-tools.js";
|
|
37
|
-
import { getFileSystemTools } from "../tools/filesystem-tools.js";
|
|
37
|
+
import { getFileSystemTools, isRuntimeRegistrationPolicy, setRuntimeRegistrationPolicy, setDirectoryRegistrationConsentHandler, RUNTIME_REGISTRATION_POLICIES, } from "../tools/filesystem-tools.js";
|
|
38
38
|
import { getSearchTools } from "../tools/search-tools.js";
|
|
39
39
|
import { initializeShellTool, getShellTools } from "../tools/shell-tool.js";
|
|
40
40
|
// Configuration storage
|
|
41
41
|
let allowedDirectories = [];
|
|
42
42
|
let approvedFoldersFromArgs = [];
|
|
43
43
|
let approvedCommandsFromArgs = [];
|
|
44
|
+
let dangerousCommandsFromArgs = [];
|
|
44
45
|
let ignoredFolders = [];
|
|
45
46
|
let enabledToolCategories = [];
|
|
46
47
|
let enabledTools = [];
|
|
48
|
+
let runtimeRegistrationPolicy = "confirm";
|
|
49
|
+
let envFileFromArgs = null;
|
|
47
50
|
// Command line argument parsing
|
|
48
51
|
function parseArguments() {
|
|
49
52
|
const args = process.argv.slice(2);
|
|
@@ -65,6 +68,12 @@ function parseArguments() {
|
|
|
65
68
|
console.error(" --enabled-tool-categories <cats...> Enable specific tool categories (comma-separated)");
|
|
66
69
|
console.error(" --enabled-tools <tools...> Enable specific tools (comma-separated)");
|
|
67
70
|
console.error(" --approved-commands <cmds...> Allow specific shell commands (comma-separated)");
|
|
71
|
+
console.error(" --allow-dangerous-commands <cmds...> Approved commands that may run even when they match");
|
|
72
|
+
console.error(" a dangerous pattern (e.g. rm -rf); comma-separated");
|
|
73
|
+
console.error(" --commands-env-file <path> Read APPROVED_COMMANDS from this .env-format file (only");
|
|
74
|
+
console.error(" that key is used; nothing is added to the environment)");
|
|
75
|
+
console.error(" --runtime-registration <mode> register_directory policy: confirm (default; user must");
|
|
76
|
+
console.error(" approve via the MCP client), allow (no prompt), deny");
|
|
68
77
|
console.error(" --help, -h Show this help message");
|
|
69
78
|
console.error(" --version, -v Show version information");
|
|
70
79
|
console.error("");
|
|
@@ -157,6 +166,59 @@ function parseArguments() {
|
|
|
157
166
|
.filter((tool) => tool.length > 0));
|
|
158
167
|
continue;
|
|
159
168
|
}
|
|
169
|
+
if (arg === "--runtime-registration" ||
|
|
170
|
+
arg.startsWith("--runtime-registration=")) {
|
|
171
|
+
let value;
|
|
172
|
+
if (arg.includes("=")) {
|
|
173
|
+
value = arg.slice(arg.indexOf("=") + 1);
|
|
174
|
+
}
|
|
175
|
+
else if (i + 1 < args.length && !args[i + 1].startsWith("--")) {
|
|
176
|
+
value = args[++i];
|
|
177
|
+
}
|
|
178
|
+
if (!value || !isRuntimeRegistrationPolicy(value.trim())) {
|
|
179
|
+
console.error(`Error: Invalid value for --runtime-registration: '${value ?? ""}'. ` +
|
|
180
|
+
`Expected one of: ${RUNTIME_REGISTRATION_POLICIES.join(", ")}`);
|
|
181
|
+
console.error("Run with --help for usage information.");
|
|
182
|
+
process.exit(1);
|
|
183
|
+
}
|
|
184
|
+
runtimeRegistrationPolicy = value.trim();
|
|
185
|
+
parsingIgnoredFolders = false;
|
|
186
|
+
parsingApprovedFolders = false;
|
|
187
|
+
parsingEnabledToolCategories = false;
|
|
188
|
+
parsingEnabledTools = false;
|
|
189
|
+
parsingApprovedCommands = false;
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
// Node.js itself scans ALL argv entries (even after the script) for
|
|
193
|
+
// --env-file and applies NODE_OPTIONS from that file to this process,
|
|
194
|
+
// so the server must not use that name.
|
|
195
|
+
if (arg === "--env-file" || arg.startsWith("--env-file=")) {
|
|
196
|
+
console.error("Error: --env-file is reserved by Node.js. Use --commands-env-file <path> instead.");
|
|
197
|
+
console.error("Run with --help for usage information.");
|
|
198
|
+
process.exit(1);
|
|
199
|
+
}
|
|
200
|
+
if (arg === "--commands-env-file" ||
|
|
201
|
+
arg.startsWith("--commands-env-file=")) {
|
|
202
|
+
let value;
|
|
203
|
+
if (arg.includes("=")) {
|
|
204
|
+
value = arg.slice(arg.indexOf("=") + 1);
|
|
205
|
+
}
|
|
206
|
+
else if (i + 1 < args.length && !args[i + 1].startsWith("--")) {
|
|
207
|
+
value = args[++i];
|
|
208
|
+
}
|
|
209
|
+
if (!value || value.trim().length === 0) {
|
|
210
|
+
console.error("Error: --commands-env-file requires a file path");
|
|
211
|
+
console.error("Run with --help for usage information.");
|
|
212
|
+
process.exit(1);
|
|
213
|
+
}
|
|
214
|
+
envFileFromArgs = value.trim();
|
|
215
|
+
parsingIgnoredFolders = false;
|
|
216
|
+
parsingApprovedFolders = false;
|
|
217
|
+
parsingEnabledToolCategories = false;
|
|
218
|
+
parsingEnabledTools = false;
|
|
219
|
+
parsingApprovedCommands = false;
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
160
222
|
if (arg === "--approved-commands") {
|
|
161
223
|
parsingApprovedCommands = true;
|
|
162
224
|
parsingIgnoredFolders = false;
|
|
@@ -176,6 +238,23 @@ function parseArguments() {
|
|
|
176
238
|
.filter((cmd) => cmd.length > 0));
|
|
177
239
|
continue;
|
|
178
240
|
}
|
|
241
|
+
if (arg === "--allow-dangerous-commands") {
|
|
242
|
+
parsingApprovedCommands = false;
|
|
243
|
+
parsingIgnoredFolders = false;
|
|
244
|
+
parsingApprovedFolders = false;
|
|
245
|
+
parsingEnabledToolCategories = false;
|
|
246
|
+
parsingEnabledTools = false;
|
|
247
|
+
const commands = [];
|
|
248
|
+
while (i + 1 < args.length && !args[i + 1].startsWith("--")) {
|
|
249
|
+
commands.push(args[i + 1]);
|
|
250
|
+
i++;
|
|
251
|
+
}
|
|
252
|
+
dangerousCommandsFromArgs = commands.flatMap((c) => c
|
|
253
|
+
.split(",")
|
|
254
|
+
.map((cmd) => cmd.trim())
|
|
255
|
+
.filter((cmd) => cmd.length > 0));
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
179
258
|
// If we're not parsing a flag value, this might be an unrecognized argument
|
|
180
259
|
if (!parsingIgnoredFolders &&
|
|
181
260
|
!parsingApprovedFolders &&
|
|
@@ -328,6 +407,7 @@ async function initializeDirectories() {
|
|
|
328
407
|
setIgnoredFolders(ignoredFolders);
|
|
329
408
|
// Set individual enabled tools (categories are combined dynamically in tool handlers)
|
|
330
409
|
setEnabledTools(enabledTools);
|
|
410
|
+
setRuntimeRegistrationPolicy(runtimeRegistrationPolicy);
|
|
331
411
|
// Load shell command configuration
|
|
332
412
|
let finalApprovedCommands = [];
|
|
333
413
|
// Priority 1: --approved-commands from CLI (supersedes .env)
|
|
@@ -337,29 +417,34 @@ async function initializeDirectories() {
|
|
|
337
417
|
console.error(` Approved commands (from CLI): ${finalApprovedCommands.join(", ")}`);
|
|
338
418
|
}
|
|
339
419
|
}
|
|
340
|
-
else {
|
|
341
|
-
// Priority 2:
|
|
420
|
+
else if (envFileFromArgs) {
|
|
421
|
+
// Priority 2: explicit --commands-env-file. Security (VFO-08): never load a .env
|
|
422
|
+
// implicitly from the working directory, and never inject its variables
|
|
423
|
+
// into process.env (shell commands inherit it). Only APPROVED_COMMANDS
|
|
424
|
+
// is read.
|
|
425
|
+
const envPath = path.resolve(process.cwd(), expandHome(envFileFromArgs));
|
|
342
426
|
try {
|
|
343
|
-
const
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
console.error(` Approved commands (from .env): ${finalApprovedCommands.join(", ")}`);
|
|
351
|
-
}
|
|
427
|
+
const parsedEnv = dotenv.parse(readFileSync(envPath));
|
|
428
|
+
finalApprovedCommands = (parsedEnv.APPROVED_COMMANDS ?? "")
|
|
429
|
+
.split(",")
|
|
430
|
+
.map((c) => c.trim())
|
|
431
|
+
.filter((c) => c.length > 0);
|
|
432
|
+
if (!isMCP) {
|
|
433
|
+
console.error(` Approved commands (from ${envPath}): ${finalApprovedCommands.join(", ")}`);
|
|
352
434
|
}
|
|
353
435
|
}
|
|
354
436
|
catch (error) {
|
|
437
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
438
|
+
console.error(`Error: Could not read --commands-env-file ${envPath}: ${message}`);
|
|
439
|
+
// In MCP mode, don't exit - continue with no approved commands
|
|
355
440
|
if (!isMCP) {
|
|
356
|
-
|
|
441
|
+
process.exit(1);
|
|
357
442
|
}
|
|
358
443
|
}
|
|
359
444
|
}
|
|
360
445
|
// Initialize shell tool with approved commands
|
|
361
446
|
if (finalApprovedCommands.length > 0) {
|
|
362
|
-
initializeShellTool(finalApprovedCommands);
|
|
447
|
+
initializeShellTool(finalApprovedCommands, dangerousCommandsFromArgs);
|
|
363
448
|
if (!isMCP) {
|
|
364
449
|
console.error(`Initialized shell tool with ${finalApprovedCommands.length} approved command(s)`);
|
|
365
450
|
}
|
|
@@ -399,25 +484,46 @@ const server = new Server({
|
|
|
399
484
|
// receive "Method not found" errors, and fail to enable the toggle
|
|
400
485
|
},
|
|
401
486
|
});
|
|
402
|
-
//
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
487
|
+
// The SDK's built-in initialize handler (_oninitialize) is left in place.
|
|
488
|
+
// Overriding it via setRequestHandler breaks the SDK's internal state machine:
|
|
489
|
+
// _clientCapabilities and _clientVersion never get set, and protocol version
|
|
490
|
+
// negotiation is bypassed (should respond with max supported ≤ client's requested).
|
|
491
|
+
// Instructions are injected at runtime in runServer() after directories initialize.
|
|
492
|
+
// Security (VFO-07): under the default "confirm" policy, register_directory
|
|
493
|
+
// asks the human through MCP elicitation before widening the sandbox.
|
|
494
|
+
// Clients without elicitation support get "unsupported" (registration refused).
|
|
495
|
+
const REGISTRATION_CONSENT_TIMEOUT_MS = 5 * 60 * 1000;
|
|
496
|
+
setDirectoryRegistrationConsentHandler(async (realPath) => {
|
|
497
|
+
if (!server.getClientCapabilities()?.elicitation) {
|
|
498
|
+
return "unsupported";
|
|
499
|
+
}
|
|
500
|
+
try {
|
|
501
|
+
const result = await server.elicitInput({
|
|
502
|
+
message: `Allow the AI assistant to read and modify files in:\n${realPath}\n\n` +
|
|
503
|
+
"Only approve directories you trust it to change.",
|
|
504
|
+
requestedSchema: {
|
|
505
|
+
type: "object",
|
|
506
|
+
properties: {
|
|
507
|
+
allow: {
|
|
508
|
+
type: "boolean",
|
|
509
|
+
title: "Allow access",
|
|
510
|
+
description: "Grant read/write access to this directory for this session",
|
|
511
|
+
},
|
|
512
|
+
},
|
|
513
|
+
required: ["allow"],
|
|
412
514
|
},
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
515
|
+
}, { timeout: REGISTRATION_CONSENT_TIMEOUT_MS });
|
|
516
|
+
return result.action === "accept" && result.content?.allow === true
|
|
517
|
+
? "accepted"
|
|
518
|
+
: "declined";
|
|
519
|
+
}
|
|
520
|
+
catch (error) {
|
|
521
|
+
if (error instanceof McpError && error.code === ErrorCode.MethodNotFound) {
|
|
522
|
+
return "unsupported";
|
|
523
|
+
}
|
|
524
|
+
// Timeouts, invalid responses, transport errors: fail closed
|
|
525
|
+
return "declined";
|
|
526
|
+
}
|
|
421
527
|
});
|
|
422
528
|
// Ping handler - for health checks
|
|
423
529
|
server.setRequestHandler(PingRequestSchema, async () => {
|
|
@@ -623,18 +729,19 @@ server.setNotificationHandler(RootsListChangedNotificationSchema, async () => {
|
|
|
623
729
|
}
|
|
624
730
|
});
|
|
625
731
|
// Handles post-initialization setup, specifically checking for and fetching MCP roots.
|
|
626
|
-
|
|
732
|
+
// listRoots() is fired detached (no await) so it doesn't block the initialize handshake.
|
|
733
|
+
// Blocking here deadlocks: the client won't respond to roots/list until after initialize
|
|
734
|
+
// completes, but initialize won't complete until this callback returns.
|
|
735
|
+
server.oninitialized = () => {
|
|
627
736
|
const clientCapabilities = server.getClientCapabilities();
|
|
628
737
|
if (clientCapabilities?.roots) {
|
|
629
|
-
|
|
630
|
-
const response = await server.listRoots();
|
|
738
|
+
server.listRoots().then(async (response) => {
|
|
631
739
|
if (response && "roots" in response) {
|
|
632
740
|
await updateAllowedDirectoriesFromRoots(response.roots);
|
|
633
741
|
}
|
|
634
|
-
}
|
|
635
|
-
catch (error) {
|
|
742
|
+
}).catch(() => {
|
|
636
743
|
// Silently handle errors - dynamic access will work via register_directory tool
|
|
637
|
-
}
|
|
744
|
+
});
|
|
638
745
|
}
|
|
639
746
|
};
|
|
640
747
|
// Start server
|
|
@@ -658,6 +765,10 @@ export async function runServer() {
|
|
|
658
765
|
}
|
|
659
766
|
// In MCP mode, silently continue - server can work without approved folders
|
|
660
767
|
}
|
|
768
|
+
// Inject instructions now that directories are known. The SDK's built-in
|
|
769
|
+
// _oninitialize reads this._instructions when building the initialize response.
|
|
770
|
+
server._instructions =
|
|
771
|
+
generateServerDescription();
|
|
661
772
|
const transport = new StdioServerTransport();
|
|
662
773
|
await server.connect(transport);
|
|
663
774
|
// Minimal logging to avoid issues with MCP clients
|
|
@@ -1,14 +1,67 @@
|
|
|
1
1
|
import fs from "fs/promises";
|
|
2
|
+
import os from "os";
|
|
2
3
|
import path from "path";
|
|
3
4
|
import { minimatch } from "minimatch";
|
|
4
5
|
import { zodToJsonSchema } from "zod-to-json-schema";
|
|
5
6
|
import { ToolSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
6
7
|
import { expandHome, normalizePath } from "../utils/path-utils.js";
|
|
7
|
-
import { isPathWithinAllowedDirectories } from "../utils/path-validation.js";
|
|
8
8
|
import { MakeDirectoryArgsSchema, ListDirectoryArgsSchema, MoveFileArgsSchema, GetFileInfoArgsSchema, RegisterDirectoryArgsSchema, FileOperationsArgsSchema, DeleteFilesArgsSchema, } from "../types/index.js";
|
|
9
|
-
import { validatePath, getFileStats, formatSize, getAllowedDirectories, setAllowedDirectories, shouldIgnoreFolder, } from "../utils/lib.js";
|
|
9
|
+
import { validatePath, getFileStats, formatSize, getAllowedDirectories, setAllowedDirectories, shouldIgnoreFolder, isPathCanonicallyAllowed, ensureDirectoryWithinAllowed, } from "../utils/lib.js";
|
|
10
10
|
import { createEmptyObjectSchema, createPathArraySchema, sanitizeToolInputSchema, } from "../utils/tool-schema.js";
|
|
11
11
|
const ToolInputSchema = ToolSchema.shape.inputSchema;
|
|
12
|
+
// ============================================================================
|
|
13
|
+
// RUNTIME DIRECTORY REGISTRATION POLICY (register_directory)
|
|
14
|
+
// ============================================================================
|
|
15
|
+
//
|
|
16
|
+
// Security (VFO-07): register_directory widens the sandbox, so by default it
|
|
17
|
+
// requires a human to confirm each registration through the MCP client
|
|
18
|
+
// (elicitation). The server entry point installs the consent handler; when
|
|
19
|
+
// none is installed (or the client cannot prompt), registration is refused.
|
|
20
|
+
export const RUNTIME_REGISTRATION_POLICIES = [
|
|
21
|
+
"confirm",
|
|
22
|
+
"allow",
|
|
23
|
+
"deny",
|
|
24
|
+
];
|
|
25
|
+
let runtimeRegistrationPolicy = "confirm";
|
|
26
|
+
let directoryRegistrationConsentHandler = null;
|
|
27
|
+
export function isRuntimeRegistrationPolicy(value) {
|
|
28
|
+
return RUNTIME_REGISTRATION_POLICIES.includes(value);
|
|
29
|
+
}
|
|
30
|
+
export function setRuntimeRegistrationPolicy(policy) {
|
|
31
|
+
if (!isRuntimeRegistrationPolicy(policy)) {
|
|
32
|
+
throw new Error(`Invalid runtime registration policy: ${policy}. Expected one of: ${RUNTIME_REGISTRATION_POLICIES.join(", ")}`);
|
|
33
|
+
}
|
|
34
|
+
runtimeRegistrationPolicy = policy;
|
|
35
|
+
}
|
|
36
|
+
export function getRuntimeRegistrationPolicy() {
|
|
37
|
+
return runtimeRegistrationPolicy;
|
|
38
|
+
}
|
|
39
|
+
export function setDirectoryRegistrationConsentHandler(handler) {
|
|
40
|
+
directoryRegistrationConsentHandler = handler;
|
|
41
|
+
}
|
|
42
|
+
function samePath(a, b) {
|
|
43
|
+
const left = path.resolve(a);
|
|
44
|
+
const right = path.resolve(b);
|
|
45
|
+
return process.platform === "win32"
|
|
46
|
+
? left.toLowerCase() === right.toLowerCase()
|
|
47
|
+
: left === right;
|
|
48
|
+
}
|
|
49
|
+
async function describeSensitiveDirectory(realPath) {
|
|
50
|
+
if (path.parse(realPath).root === realPath) {
|
|
51
|
+
return "a filesystem root";
|
|
52
|
+
}
|
|
53
|
+
let home = os.homedir();
|
|
54
|
+
try {
|
|
55
|
+
home = await fs.realpath(home);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// Fall back to the lexical home directory
|
|
59
|
+
}
|
|
60
|
+
if (home && samePath(realPath, home)) {
|
|
61
|
+
return "the user's home directory";
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
12
65
|
export function getFileSystemTools() {
|
|
13
66
|
// Get current allowed directories for dynamic descriptions
|
|
14
67
|
const currentAllowedDirs = getAllowedDirectories();
|
|
@@ -65,7 +118,10 @@ export function getFileSystemTools() {
|
|
|
65
118
|
name: "register_directory",
|
|
66
119
|
description: "Register a directory for access. This allows the AI to dynamically gain access " +
|
|
67
120
|
"to directories specified by the human user during conversation. The directory " +
|
|
68
|
-
"and all its subdirectories will become accessible for all filesystem operations." +
|
|
121
|
+
"and all its subdirectories will become accessible for all filesystem operations. " +
|
|
122
|
+
"By default the user must confirm each registration in their MCP client " +
|
|
123
|
+
"(registration is refused if the client cannot show a confirmation prompt); " +
|
|
124
|
+
"filesystem roots and the home directory cannot be registered at runtime." +
|
|
69
125
|
generateApprovedDirsText(),
|
|
70
126
|
inputSchema: sanitizeToolInputSchema(zodToJsonSchema(RegisterDirectoryArgsSchema)),
|
|
71
127
|
},
|
|
@@ -456,25 +512,26 @@ export async function handleFileSystemTool(name, args) {
|
|
|
456
512
|
const pathsToCreate = Array.isArray(pathsInput)
|
|
457
513
|
? pathsInput
|
|
458
514
|
: [pathsInput];
|
|
459
|
-
// Validate all paths first (atomic - fail before any creation)
|
|
460
|
-
|
|
461
|
-
|
|
515
|
+
// Validate all paths first (atomic - fail before any creation).
|
|
516
|
+
// Security: the check is canonical (lexical + realpath + physical), so a
|
|
517
|
+
// symlink or junction inside an allowed directory that points outside it
|
|
518
|
+
// is rejected here instead of being followed by mkdir (VFO-05).
|
|
519
|
+
const validatedPaths = [];
|
|
520
|
+
for (const dirPath of pathsToCreate) {
|
|
462
521
|
const expandedPath = expandHome(dirPath);
|
|
463
|
-
// Resolve to absolute path - required by isPathWithinAllowedDirectories
|
|
464
522
|
const absolutePath = path.isAbsolute(expandedPath)
|
|
465
523
|
? path.resolve(expandedPath)
|
|
466
524
|
: path.resolve(process.cwd(), expandedPath);
|
|
467
|
-
|
|
468
|
-
// Use secure path validation function to prevent prefix collision attacks
|
|
469
|
-
// (CVE-2025-54794 pattern: ensures path separator is required, not just prefix match)
|
|
470
|
-
if (!isPathWithinAllowedDirectories(normalized, allowedDirs)) {
|
|
525
|
+
if (!(await isPathCanonicallyAllowed(absolutePath, process.cwd()))) {
|
|
471
526
|
throw new Error(`Access denied: Path ${dirPath} is not within allowed directories`);
|
|
472
527
|
}
|
|
473
|
-
|
|
474
|
-
}
|
|
475
|
-
// All validated - now create them
|
|
476
|
-
|
|
477
|
-
|
|
528
|
+
validatedPaths.push({ original: dirPath, absolutePath });
|
|
529
|
+
}
|
|
530
|
+
// All validated - now create them. ensureDirectoryWithinAllowed creates
|
|
531
|
+
// missing segments one at a time and re-checks the realpath of each, so
|
|
532
|
+
// a link swapped in after validation cannot redirect creation.
|
|
533
|
+
const results = await Promise.all(validatedPaths.map(async ({ original, absolutePath }) => {
|
|
534
|
+
await ensureDirectoryWithinAllowed(absolutePath);
|
|
478
535
|
return original;
|
|
479
536
|
}));
|
|
480
537
|
// Format response based on single vs batch
|
|
@@ -506,6 +563,35 @@ export async function handleFileSystemTool(name, args) {
|
|
|
506
563
|
}
|
|
507
564
|
const validSourcePath = await validatePath(parsed.data.source);
|
|
508
565
|
const validDestPath = await validatePath(parsed.data.destination);
|
|
566
|
+
// fs.rename silently replaces an existing destination; move_file is
|
|
567
|
+
// documented to never overwrite.
|
|
568
|
+
try {
|
|
569
|
+
const destStats = await fs.lstat(validDestPath, { bigint: true });
|
|
570
|
+
const sourceStats = await fs.lstat(validSourcePath, { bigint: true });
|
|
571
|
+
const sameFile = destStats.ino === sourceStats.ino && destStats.dev === sourceStats.dev;
|
|
572
|
+
// A case-only rename on a case-insensitive file system "finds" the
|
|
573
|
+
// source itself at the destination; that is not an overwrite.
|
|
574
|
+
// validatePath returns the existing (old-case) real path, so rename to
|
|
575
|
+
// the requested name inside the same, already-validated directory.
|
|
576
|
+
if (sameFile) {
|
|
577
|
+
await fs.rename(validSourcePath, path.join(path.dirname(validDestPath), path.basename(parsed.data.destination)));
|
|
578
|
+
return {
|
|
579
|
+
content: [
|
|
580
|
+
{
|
|
581
|
+
type: "text",
|
|
582
|
+
text: `Successfully moved ${parsed.data.source} to ${parsed.data.destination}`,
|
|
583
|
+
},
|
|
584
|
+
],
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
throw new Error(`Destination already exists: ${parsed.data.destination}. ` +
|
|
588
|
+
`move_file never overwrites; use file_operations with onConflict: "overwrite" to replace it.`);
|
|
589
|
+
}
|
|
590
|
+
catch (error) {
|
|
591
|
+
if (error.code !== "ENOENT") {
|
|
592
|
+
throw error;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
509
595
|
await fs.rename(validSourcePath, validDestPath);
|
|
510
596
|
return {
|
|
511
597
|
content: [
|
|
@@ -541,43 +627,82 @@ export async function handleFileSystemTool(name, args) {
|
|
|
541
627
|
}
|
|
542
628
|
const expandedPath = expandHome(parsed.data.path);
|
|
543
629
|
const absolutePath = path.resolve(expandedPath);
|
|
544
|
-
|
|
545
|
-
//
|
|
630
|
+
// Resolve links so the directory that is shown to the user and stored
|
|
631
|
+
// in the allowed list is the one that will actually be accessed.
|
|
632
|
+
let realPath;
|
|
546
633
|
try {
|
|
547
|
-
|
|
634
|
+
realPath = await fs.realpath(absolutePath);
|
|
635
|
+
const stats = await fs.stat(realPath);
|
|
548
636
|
if (!stats.isDirectory()) {
|
|
549
637
|
throw new Error(`Path ${absolutePath} is not a directory`);
|
|
550
638
|
}
|
|
551
639
|
}
|
|
552
640
|
catch (error) {
|
|
553
|
-
|
|
641
|
+
const code = error.code;
|
|
642
|
+
if (code === "ENOENT" || code === "ENOTDIR") {
|
|
554
643
|
throw new Error(`Directory ${absolutePath} does not exist`);
|
|
555
644
|
}
|
|
556
645
|
throw error;
|
|
557
646
|
}
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
if (
|
|
561
|
-
setAllowedDirectories([...currentDirs, normalizedPath]);
|
|
647
|
+
const normalizedPath = normalizePath(realPath) || realPath;
|
|
648
|
+
// Already accessible: nothing to widen, so no prompt is needed
|
|
649
|
+
if (getAllowedDirectories().includes(normalizedPath)) {
|
|
562
650
|
return {
|
|
563
651
|
content: [
|
|
564
652
|
{
|
|
565
653
|
type: "text",
|
|
566
|
-
text: `
|
|
654
|
+
text: `Directory already registered: ${parsed.data.path} (${normalizedPath})`,
|
|
567
655
|
},
|
|
568
656
|
],
|
|
569
657
|
};
|
|
570
658
|
}
|
|
571
|
-
|
|
659
|
+
if (await isPathCanonicallyAllowed(realPath, process.cwd())) {
|
|
572
660
|
return {
|
|
573
661
|
content: [
|
|
574
662
|
{
|
|
575
663
|
type: "text",
|
|
576
|
-
text: `Directory already
|
|
664
|
+
text: `Directory already accessible: ${parsed.data.path} (${normalizedPath}) is inside an allowed directory`,
|
|
577
665
|
},
|
|
578
666
|
],
|
|
579
667
|
};
|
|
580
668
|
}
|
|
669
|
+
const policy = getRuntimeRegistrationPolicy();
|
|
670
|
+
if (policy === "deny") {
|
|
671
|
+
throw new Error(`Runtime directory registration is disabled on this server (--runtime-registration deny). ` +
|
|
672
|
+
`To grant access to ${normalizedPath}, add it to --approved-folders in the MCP server configuration.`);
|
|
673
|
+
}
|
|
674
|
+
if (policy !== "allow") {
|
|
675
|
+
const sensitive = await describeSensitiveDirectory(realPath);
|
|
676
|
+
if (sensitive) {
|
|
677
|
+
throw new Error(`Refusing to register ${normalizedPath}: it is ${sensitive}. ` +
|
|
678
|
+
`Register a more specific folder, add it to --approved-folders, ` +
|
|
679
|
+
`or start the server with --runtime-registration allow.`);
|
|
680
|
+
}
|
|
681
|
+
const consent = directoryRegistrationConsentHandler
|
|
682
|
+
? await directoryRegistrationConsentHandler(normalizedPath)
|
|
683
|
+
: "unsupported";
|
|
684
|
+
if (consent === "unsupported") {
|
|
685
|
+
throw new Error(`Cannot register ${normalizedPath}: registering directories at runtime requires user confirmation, ` +
|
|
686
|
+
`but this MCP client does not support confirmation prompts (MCP elicitation). ` +
|
|
687
|
+
`Add the folder via --approved-folders, or start the server with --runtime-registration allow.`);
|
|
688
|
+
}
|
|
689
|
+
if (consent !== "accepted") {
|
|
690
|
+
throw new Error(`User declined access to ${normalizedPath}`);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
// Re-read: the list may have changed while waiting for the user
|
|
694
|
+
const currentDirs = getAllowedDirectories();
|
|
695
|
+
if (!currentDirs.includes(normalizedPath)) {
|
|
696
|
+
setAllowedDirectories([...currentDirs, normalizedPath]);
|
|
697
|
+
}
|
|
698
|
+
return {
|
|
699
|
+
content: [
|
|
700
|
+
{
|
|
701
|
+
type: "text",
|
|
702
|
+
text: `Successfully registered directory: ${parsed.data.path} (${normalizedPath})`,
|
|
703
|
+
},
|
|
704
|
+
],
|
|
705
|
+
};
|
|
581
706
|
}
|
|
582
707
|
case "list_allowed_directories": {
|
|
583
708
|
return {
|
|
@@ -666,6 +791,11 @@ export async function handleFileSystemTool(name, args) {
|
|
|
666
791
|
await fs.rename(file.validSource, file.validDest);
|
|
667
792
|
break;
|
|
668
793
|
case "copy":
|
|
794
|
+
// validSource is the realpath returned by validatePath, so a
|
|
795
|
+
// top-level source that is itself a symlink/junction has already
|
|
796
|
+
// been resolved and its real target checked against the allowed
|
|
797
|
+
// directories. Links *inside* a copied directory are refused by
|
|
798
|
+
// copyDirectoryRecursive.
|
|
669
799
|
const stats = await fs.stat(file.validSource);
|
|
670
800
|
if (stats.isDirectory()) {
|
|
671
801
|
await copyDirectoryRecursive(file.validSource, file.validDest);
|
|
@@ -903,22 +1033,75 @@ export async function handleFileSystemTool(name, args) {
|
|
|
903
1033
|
throw new Error(`Unknown filesystem tool: ${name}`);
|
|
904
1034
|
}
|
|
905
1035
|
}
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
const entries = await fs.readdir(
|
|
912
|
-
|
|
1036
|
+
function symlinkCopyError(entryPath) {
|
|
1037
|
+
return new Error(`Refusing to copy symbolic link inside directory: ${entryPath} ` +
|
|
1038
|
+
`(symbolic links are not copied for security reasons)`);
|
|
1039
|
+
}
|
|
1040
|
+
async function collectDirectoryCopyPlan(root, relativeDir = "", plan = []) {
|
|
1041
|
+
const entries = await fs.readdir(path.join(root, relativeDir), {
|
|
1042
|
+
withFileTypes: true,
|
|
1043
|
+
});
|
|
913
1044
|
for (const entry of entries) {
|
|
914
|
-
const
|
|
915
|
-
const
|
|
916
|
-
|
|
917
|
-
|
|
1045
|
+
const relativePath = path.join(relativeDir, entry.name);
|
|
1046
|
+
const entryPath = path.join(root, relativePath);
|
|
1047
|
+
// lstat never follows links; Node reports junctions as symbolic links too
|
|
1048
|
+
const stats = await fs.lstat(entryPath);
|
|
1049
|
+
if (entry.isSymbolicLink() || stats.isSymbolicLink()) {
|
|
1050
|
+
throw symlinkCopyError(entryPath);
|
|
1051
|
+
}
|
|
1052
|
+
if (stats.isDirectory()) {
|
|
1053
|
+
plan.push({ relativePath, type: "directory" });
|
|
1054
|
+
await collectDirectoryCopyPlan(root, relativePath, plan);
|
|
1055
|
+
}
|
|
1056
|
+
else if (stats.isFile()) {
|
|
1057
|
+
plan.push({ relativePath, type: "file" });
|
|
918
1058
|
}
|
|
919
1059
|
else {
|
|
920
|
-
|
|
1060
|
+
throw new Error(`Refusing to copy special file inside directory: ${entryPath} ` +
|
|
1061
|
+
`(only regular files and directories are copied)`);
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
return plan;
|
|
1065
|
+
}
|
|
1066
|
+
async function copyDirectoryRecursive(source, destination) {
|
|
1067
|
+
const relativeDest = path.relative(source, destination);
|
|
1068
|
+
if (relativeDest === "" ||
|
|
1069
|
+
(!relativeDest.startsWith("..") && !path.isAbsolute(relativeDest))) {
|
|
1070
|
+
throw new Error(`Cannot copy a directory into itself: ${source} to ${destination}`);
|
|
1071
|
+
}
|
|
1072
|
+
// Validate the entire source tree before creating or copying anything
|
|
1073
|
+
const plan = await collectDirectoryCopyPlan(source);
|
|
1074
|
+
// Destination directories are created segment by segment with their
|
|
1075
|
+
// realpath re-checked, so an existing link at the destination cannot
|
|
1076
|
+
// redirect the copy outside the allowed directories.
|
|
1077
|
+
await ensureDirectoryWithinAllowed(destination);
|
|
1078
|
+
for (const entry of plan) {
|
|
1079
|
+
const sourcePath = path.join(source, entry.relativePath);
|
|
1080
|
+
const destPath = path.join(destination, entry.relativePath);
|
|
1081
|
+
if (entry.type === "directory") {
|
|
1082
|
+
await ensureDirectoryWithinAllowed(destPath);
|
|
1083
|
+
continue;
|
|
1084
|
+
}
|
|
1085
|
+
// Re-check the source right before copying in case it was swapped for a
|
|
1086
|
+
// link after the tree was validated.
|
|
1087
|
+
const sourceStats = await fs.lstat(sourcePath);
|
|
1088
|
+
if (sourceStats.isSymbolicLink() || !sourceStats.isFile()) {
|
|
1089
|
+
throw symlinkCopyError(sourcePath);
|
|
1090
|
+
}
|
|
1091
|
+
// Never write through an existing link at the destination
|
|
1092
|
+
let destIsLink = false;
|
|
1093
|
+
try {
|
|
1094
|
+
destIsLink = (await fs.lstat(destPath)).isSymbolicLink();
|
|
1095
|
+
}
|
|
1096
|
+
catch (error) {
|
|
1097
|
+
if (error.code !== "ENOENT") {
|
|
1098
|
+
throw error;
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
if (destIsLink) {
|
|
1102
|
+
throw new Error(`Refusing to overwrite symbolic link at destination: ${destPath}`);
|
|
921
1103
|
}
|
|
1104
|
+
await fs.copyFile(sourcePath, destPath);
|
|
922
1105
|
}
|
|
923
1106
|
}
|
|
924
1107
|
//# sourceMappingURL=filesystem-tools.js.map
|