Skip to content

feat(filesystem): add --ignore-write option to block writes to sensit… #1901

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
15 changes: 15 additions & 0 deletions src/filesystem/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <allowed-directory> [additional-directories...] [--ignore-write <pattern1> <pattern2> ...]
```

- `<allowed-directory>`: One or more directories the server is allowed to access.
- `--ignore-write <pattern1> <pattern2> ...`: (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
Expand Down
87 changes: 62 additions & 25 deletions src/filesystem/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,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 <allowed-directory> [additional-directories...] [--ignore-write <pattern1> <pattern2> ...]
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 <allowed-directory> [additional-directories...] [--ignore-write <pattern1> <pattern2> ...]");
process.exit(1);
}



// Normalize all paths consistently
function normalizePath(p: string): string {
return path.normalize(p);
Expand Down Expand Up @@ -57,6 +78,7 @@ let allowedDirectories = await Promise.all(
return normalizePath(absolute);
}
})

);

// Validate that all directories exist and are accessible
Expand Down Expand Up @@ -659,35 +681,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) {
Expand Down