diff --git a/src/filesystem/README.md b/src/filesystem/README.md index ac63f39a5f..78d6a76bc2 100644 --- a/src/filesystem/README.md +++ b/src/filesystem/README.md @@ -62,6 +62,21 @@ The server's directory access control follows this flow: +**Security Note**: You can prevent write operations to sensitive files (such as `.env`, `.env.*`, or any custom pattern) by using the `--ignore-write` command-line argument. See below for usage. + +## Usage + +### Command-line Arguments + +``` +mcp-server-filesystem [additional-directories...] [--ignore-write ...] +``` + +- ``: One or more directories the server is allowed to access. +- `--ignore-write ...`: (Optional) List of filenames or glob patterns to block from write operations. Example: `--ignore-write .env .env.* *.secret` + +If a file matches any of the ignore patterns, write operations to that file will be blocked, even if it is inside an allowed directory. + ## API ### Resources diff --git a/src/filesystem/index.ts b/src/filesystem/index.ts index 6723f43600..725a17857c 100644 --- a/src/filesystem/index.ts +++ b/src/filesystem/index.ts @@ -23,14 +23,35 @@ import { getValidRootDirectories } from './roots-utils.js'; // Command line argument parsing const args = process.argv.slice(2); + if (args.length === 0) { console.error("Usage: mcp-server-filesystem [allowed-directory] [additional-directories...]"); console.error("Note: Allowed directories can be provided via:"); console.error(" 1. Command-line arguments (shown above)"); console.error(" 2. MCP roots protocol (if client supports it)"); console.error("At least one directory must be provided by EITHER method for the server to operate."); + +} + +// Support: mcp-server-filesystem [additional-directories...] [--ignore-write ...] +let allowedDirs: string[] = []; +let ignoreWritePatterns: string[] = []; + +const ignoreFlagIndex = args.indexOf('--ignore-write'); +if (ignoreFlagIndex !== -1) { + allowedDirs = args.slice(0, ignoreFlagIndex); + ignoreWritePatterns = args.slice(ignoreFlagIndex + 1); +} else { + allowedDirs = args; +} + +if (allowedDirs.length === 0) { + console.error("Usage: mcp-server-filesystem [additional-directories...] [--ignore-write ...]"); + process.exit(1); } + + // Normalize all paths consistently function normalizePath(p: string): string { return path.normalize(p); @@ -58,6 +79,7 @@ let allowedDirectories = await Promise.all( return normalizePath(absolute); } }) + ); // Validate that all directories exist and are accessible @@ -728,35 +750,50 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { } const validPath = await validatePath(parsed.data.path); - try { - // Security: 'wx' flag ensures exclusive creation - fails if file/symlink exists, - // preventing writes through pre-existing symlinks - await fs.writeFile(validPath, parsed.data.content, { encoding: "utf-8", flag: 'wx' }); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'EEXIST') { - // Security: Use atomic rename to prevent race conditions where symlinks - // could be created between validation and write. Rename operations - // replace the target file atomically and don't follow symlinks. - const tempPath = `${validPath}.${randomBytes(16).toString('hex')}.tmp`; - try { - await fs.writeFile(tempPath, parsed.data.content, 'utf-8'); - await fs.rename(tempPath, validPath); - } catch (renameError) { + // Prevent writing to files matching ignoreWritePatterns + const baseName = path.basename(validPath); + const shouldIgnore = ignoreWritePatterns.some(pattern => { + // Simple glob-like match: support *.env, .env, .env.*, etc. + if (pattern.includes('*')) { + // Convert pattern to regex + const regex = new RegExp('^' + pattern.replace(/\./g, '\\.').replace(/\*/g, '.*') + '$'); + return regex.test(baseName); + } + return baseName === pattern; + }) ; + if (shouldIgnore) { + throw new Error(`Write operation to file '${baseName}' is not allowed by server policy (matched ignore pattern).`); + } + + try { + // Security: 'wx' flag ensures exclusive creation - fails if file/symlink exists, + // preventing writes through pre-existing symlinks + await fs.writeFile(validPath, parsed.data.content, { encoding: "utf-8", flag: 'wx' }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EEXIST') { + // Security: Use atomic rename to prevent race conditions where symlinks + // could be created between validation and write. Rename operations + // replace the target file atomically and don't follow symlinks. + const tempPath = `${validPath}.${randomBytes(16).toString('hex')}.tmp`; try { - await fs.unlink(tempPath); - } catch {} - throw renameError; + await fs.writeFile(tempPath, parsed.data.content, 'utf-8'); + await fs.rename(tempPath, validPath); + } catch (renameError) { + try { + await fs.unlink(tempPath); + } catch {} + throw renameError; + } + } else { + throw error; } - } else { - throw error; } - } - - return { - content: [{ type: "text", text: `Successfully wrote to ${parsed.data.path}` }], - }; - } + return { + content: [{ type: "text", text: `Successfully wrote to ${parsed.data.path}` }], + }; + } + case "edit_file": { const parsed = EditFileArgsSchema.safeParse(args); if (!parsed.success) {