@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.
@@ -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, InitializeRequestSchema, PingRequestSchema, RootsListChangedNotificationSchema, } from "@modelcontextprotocol/sdk/types.js";
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: Load from .env file
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 envPath = path.join(process.cwd(), ".env");
344
- dotenv.config({ path: envPath, quiet: true });
345
- if (process.env.APPROVED_COMMANDS) {
346
- finalApprovedCommands = process.env.APPROVED_COMMANDS.split(",")
347
- .map((c) => c.trim())
348
- .filter((c) => c.length > 0);
349
- if (!isMCP) {
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
- console.error(" Note: Could not load .env file (this is okay if using CLI args)");
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
- // Initialize handler - required for MCP protocol
403
- server.setRequestHandler(InitializeRequestSchema, async (request) => {
404
- const clientCapabilities = request.params.capabilities;
405
- return {
406
- // CRITICAL: Return the protocol version the CLIENT requested, not LATEST
407
- // Claude Desktop only supports 2025-06-18 and will disconnect if we return 2025-11-25
408
- protocolVersion: request.params.protocolVersion,
409
- capabilities: {
410
- tools: {
411
- listChanged: true,
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
- // CRITICAL: Do NOT declare resources or prompts - we don't implement them
414
- },
415
- serverInfo: {
416
- name: "vulcan-file-ops",
417
- version: VERSION,
418
- },
419
- instructions: generateServerDescription(),
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
- server.oninitialized = async () => {
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
- try {
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
- const allowedDirs = getAllowedDirectories();
461
- const validatedPaths = pathsToCreate.map((dirPath) => {
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
- const normalized = normalizePath(absolutePath);
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
- return { original: dirPath, normalized };
474
- });
475
- // All validated - now create them concurrently
476
- const results = await Promise.all(validatedPaths.map(async ({ original, normalized }) => {
477
- await fs.mkdir(normalized, { recursive: true });
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
- const normalizedPath = normalizePath(absolutePath);
545
- // Validate that the path exists and is a directory
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
- const stats = await fs.stat(absolutePath);
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
- if (error.code === "ENOENT") {
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
- // Add to allowed directories
559
- const currentDirs = getAllowedDirectories();
560
- if (!currentDirs.includes(normalizedPath)) {
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: `Successfully registered directory: ${parsed.data.path} (${normalizedPath})`,
654
+ text: `Directory already registered: ${parsed.data.path} (${normalizedPath})`,
567
655
  },
568
656
  ],
569
657
  };
570
658
  }
571
- else {
659
+ if (await isPathCanonicallyAllowed(realPath, process.cwd())) {
572
660
  return {
573
661
  content: [
574
662
  {
575
663
  type: "text",
576
- text: `Directory already registered: ${parsed.data.path} (${normalizedPath})`,
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
- // Helper function for recursive directory copying
907
- async function copyDirectoryRecursive(source, destination) {
908
- // Create destination directory
909
- await fs.mkdir(destination, { recursive: true });
910
- // Read source directory
911
- const entries = await fs.readdir(source, { withFileTypes: true });
912
- // Copy all entries
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 sourcePath = path.join(source, entry.name);
915
- const destPath = path.join(destination, entry.name);
916
- if (entry.isDirectory()) {
917
- await copyDirectoryRecursive(sourcePath, destPath);
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
- await fs.copyFile(sourcePath, destPath);
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