Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/main/mcpServers/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import DiDiMcpServer from './didi-mcp'
import DifyKnowledgeServer from './dify-knowledge'
import FetchServer from './fetch'
import FileSystemServer from './filesystem'
import { resolveFilesystemBaseDir } from './filesystem/config'
import HubServer from './hub'
import MemoryServer from './memory'
import PythonServer from './python'
Expand Down Expand Up @@ -37,7 +38,7 @@ export function createInMemoryMCPServer(
return new FetchServer().server
}
case BuiltinMCPServerNames.filesystem: {
return new FileSystemServer(envs.WORKSPACE_ROOT).server
return new FileSystemServer(resolveFilesystemBaseDir(args, envs)).server
}
case BuiltinMCPServerNames.difyKnowledge: {
const difyKey = envs.DIFY_KEY
Expand Down
85 changes: 85 additions & 0 deletions src/main/mcpServers/filesystem/__tests__/filesystem.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import fs from 'fs/promises'
import path from 'path'
import { afterEach, describe, expect, it, vi } from 'vitest'

import { resolveFilesystemBaseDir } from '../config'
import { validatePath } from '../types'

describe('filesystem MCP security', () => {
const tempDirs: string[] = []

async function createTempDir(prefix: string) {
const tempRoot = path.join(process.cwd(), '.context', 'vitest-temp')
await fs.mkdir(tempRoot, { recursive: true })
const tempDir = await fs.mkdtemp(path.join(tempRoot, prefix))
tempDirs.push(tempDir)
return tempDir
}

afterEach(async () => {
vi.restoreAllMocks()
await Promise.all(tempDirs.splice(0).map((tempDir) => fs.rm(tempDir, { recursive: true, force: true })))
})

it('prefers WORKSPACE_ROOT and falls back to args for filesystem root', () => {
expect(resolveFilesystemBaseDir(['C:/args-root'], {})).toBe('C:/args-root')
expect(resolveFilesystemBaseDir(['C:/args-root'], { WORKSPACE_ROOT: 'C:/env-root' })).toBe('C:/env-root')
expect(resolveFilesystemBaseDir([], {})).toBeUndefined()
})

it('allows paths inside the configured root and rejects paths outside it', async () => {
const workspaceRoot = await createTempDir('filesystem-root-')
const outsideRoot = await createTempDir('filesystem-outside-')
const insideFile = path.join(workspaceRoot, 'inside.txt')
const outsideFile = path.join(outsideRoot, 'outside.txt')

await fs.writeFile(insideFile, 'inside')
await fs.writeFile(outsideFile, 'outside')

await expect(validatePath(insideFile, workspaceRoot)).resolves.toBe(insideFile)
await expect(validatePath(outsideFile, workspaceRoot)).rejects.toThrow('outside the configured workspace root')
})

it('rejects symlink escapes outside the configured root', async () => {
const workspaceRoot = await createTempDir('filesystem-symlink-root-')
const outsideRoot = await createTempDir('filesystem-symlink-outside-')
const outsideFile = path.join(outsideRoot, 'secret.txt')
const symlinkPath = path.join(workspaceRoot, 'escape-link')

await fs.writeFile(outsideFile, 'top-secret')
await fs.symlink(outsideFile, symlinkPath)

await expect(validatePath(symlinkPath, workspaceRoot)).rejects.toThrow('outside the configured workspace root')
})

it('rejects relative path traversal outside the configured root', async () => {
const workspaceRoot = await createTempDir('filesystem-relative-root-')
const outsideFile = path.join(path.dirname(workspaceRoot), 'outside.txt')

await fs.writeFile(outsideFile, 'outside')

await expect(validatePath('../outside.txt', workspaceRoot)).rejects.toThrow('outside the configured workspace root')
})

it('rejects home expansion outside the configured root', async () => {
const workspaceRoot = await createTempDir('filesystem-home-root-')

await expect(validatePath('~/sensitive-file', workspaceRoot)).rejects.toThrow(
'outside the configured workspace root'
)
})

it('falls back to process.cwd() when baseDir is omitted', async () => {
const workspaceRoot = await createTempDir('filesystem-cwd-root-')
const allowedFile = path.join(workspaceRoot, 'allowed.txt')
const outsideFile = path.join(path.dirname(workspaceRoot), 'outside.txt')

await fs.writeFile(allowedFile, 'allowed')
await fs.writeFile(outsideFile, 'outside')

vi.spyOn(process, 'cwd').mockReturnValue(workspaceRoot)

await expect(validatePath('allowed.txt')).resolves.toBe(allowedFile)
await expect(validatePath('../outside.txt')).rejects.toThrow('outside the configured workspace root')
})
})
8 changes: 8 additions & 0 deletions src/main/mcpServers/filesystem/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export function resolveFilesystemBaseDir(args: string[] = [], envs: Record<string, string> = {}): string | undefined {
const envWorkspaceRoot = envs.WORKSPACE_ROOT?.trim()
if (envWorkspaceRoot) {
return envWorkspaceRoot
}

return args.find((arg) => typeof arg === 'string' && arg.trim().length > 0)?.trim()
}
18 changes: 11 additions & 7 deletions src/main/mcpServers/filesystem/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,18 @@ import {
readToolDefinition,
writeToolDefinition
} from './tools'
import { logger } from './types'
import { expandHome, logger, normalizePath } from './types'

export class FileSystemServer {
public server: Server
private baseDir: string

constructor(baseDir?: string) {
if (baseDir && path.isAbsolute(baseDir)) {
this.baseDir = baseDir
logger.info(`Using provided baseDir for filesystem MCP: ${baseDir}`)
const expandedBaseDir = baseDir ? expandHome(baseDir) : undefined

if (expandedBaseDir && path.isAbsolute(expandedBaseDir)) {
this.baseDir = normalizePath(path.resolve(expandedBaseDir))
logger.info(`Using provided baseDir for filesystem MCP: ${this.baseDir}`)
} else {
const userData = app.getPath('userData')
this.baseDir = path.join(userData, 'Data', 'Workspace')
Expand All @@ -48,17 +50,19 @@ export class FileSystemServer {
}
)

this.initialize()
this.registerHandlers()
void this.ensureBaseDir()
}

async initialize() {
private async ensureBaseDir() {
try {
await fs.mkdir(this.baseDir, { recursive: true })
} catch (error) {
logger.error('Failed to create filesystem MCP baseDir', { error, baseDir: this.baseDir })
}
}

// Register tool list handler
private registerHandlers() {
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
Expand Down
2 changes: 1 addition & 1 deletion src/main/mcpServers/filesystem/tools/delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ CAUTION: This operation cannot be undone!
- For files: simply provide the path
- For empty directories: provide the path
- For non-empty directories: set recursive=true
- The path must be an absolute path, not a relative path
- The path must resolve within the configured workspace root
- Always verify the path before deleting to avoid data loss`,
inputSchema: z.toJSONSchema(DeleteToolSchema)
}
Expand Down
2 changes: 1 addition & 1 deletion src/main/mcpServers/filesystem/tools/edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export const editToolDefinition = {
description: `Performs exact string replacements in files.

- You must use the 'read' tool at least once before editing
- The file_path must be an absolute path, not a relative path
- The file_path must resolve within the configured workspace root
- Preserve exact indentation from read output (after the line number prefix)
- Never include line number prefixes in old_string or new_string
- ALWAYS prefer editing existing files over creating new ones
Expand Down
2 changes: 1 addition & 1 deletion src/main/mcpServers/filesystem/tools/glob.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export const globToolDefinition = {
- Patterns with "/" (e.g., "src/*.ts") match relative to the search path
- Pattern syntax: * (any chars), ** (any path), {a,b} (alternatives), ? (single char)
- Results are limited to 100 files
- The path parameter must be an absolute path if specified
- The path parameter must resolve within the configured workspace root if specified
- If path is not specified, defaults to the base directory
- IMPORTANT: Omit the path field for the default directory (don't use "undefined" or "null")`,
inputSchema: z.toJSONSchema(GlobToolSchema)
Expand Down
2 changes: 1 addition & 1 deletion src/main/mcpServers/filesystem/tools/grep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export const grepToolDefinition = {
- Results are limited to 100 matches
- Binary files are automatically skipped
- Common directories (node_modules, .git, dist) are excluded
- The path parameter must be an absolute path if specified
- The path parameter must resolve within the configured workspace root if specified
- If path is not specified, defaults to the base directory`,
inputSchema: z.toJSONSchema(GrepToolSchema)
}
Expand Down
2 changes: 1 addition & 1 deletion src/main/mcpServers/filesystem/tools/ls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export const lsToolDefinition = {
- Common directories (node_modules, dist, .git) are excluded
- Hidden files (starting with .) are excluded except .env.example
- Results are limited to 100 entries
- The path parameter must be an absolute path if specified
- The path parameter must resolve within the configured workspace root if specified
- If path is not specified, defaults to the base directory`,
inputSchema: z.toJSONSchema(LsToolSchema)
}
Expand Down
4 changes: 2 additions & 2 deletions src/main/mcpServers/filesystem/tools/read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ export const readToolDefinition = {
name: 'read',
description: `Reads a file from the local filesystem.

- Assumes this tool can read all files on the machine
- The file_path parameter must be an absolute path, not a relative path
- Only files within the configured workspace root can be read
- The file_path parameter must resolve within the configured workspace root
- By default, reads up to 2000 lines starting from the beginning
- You can optionally specify a line offset and limit for long files
- Any lines longer than 2000 characters will be truncated
Expand Down
2 changes: 1 addition & 1 deletion src/main/mcpServers/filesystem/tools/write.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export const writeToolDefinition = {
- ALWAYS prefer using the 'edit' tool for existing files
- NEVER proactively create documentation files unless explicitly requested
- Parent directories will be created automatically if they don't exist
- The file_path must be an absolute path, not a relative path`,
- The file_path must resolve within the configured workspace root`,
inputSchema: z.toJSONSchema(WriteToolSchema)
}

Expand Down
63 changes: 48 additions & 15 deletions src/main/mcpServers/filesystem/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,27 +39,60 @@ export function expandHome(filepath: string): string {
return filepath
}

function normalizeForComparison(filePath: string): string {
const normalizedPath = normalizePath(path.resolve(filePath))
return isWin ? normalizedPath.toLowerCase() : normalizedPath
}

async function resolveRealOrNearestExistingPath(targetPath: string): Promise<string> {
try {
return normalizePath(await fs.realpath(targetPath))
} catch {
let currentPath = path.dirname(targetPath)

while (true) {
try {
const realCurrentPath = await fs.realpath(currentPath)
const relativeSuffix = path.relative(currentPath, targetPath)
return normalizePath(path.join(realCurrentPath, relativeSuffix))
} catch {
const parentPath = path.dirname(currentPath)
if (parentPath === currentPath) {
logger.warn('Could not resolve any existing ancestor for path', { targetPath })
return normalizePath(targetPath)
}
currentPath = parentPath
}
}
}
}

function isPathWithinRoot(targetPath: string, rootPath: string): boolean {
const normalizedTargetPath = normalizeForComparison(targetPath)
const normalizedRootPath = normalizeForComparison(rootPath)

if (normalizedTargetPath === normalizedRootPath) {
return true
}

const relativePath = path.relative(normalizedRootPath, normalizedTargetPath)
return relativePath !== '' && !relativePath.startsWith('..') && !path.isAbsolute(relativePath)
}

// Security validation
export async function validatePath(requestedPath: string, baseDir?: string): Promise<string> {
const expandedPath = expandHome(requestedPath)
const root = baseDir ?? process.cwd()
const root = expandHome(baseDir ?? process.cwd())
const absolute = path.isAbsolute(expandedPath) ? path.resolve(expandedPath) : path.resolve(root, expandedPath)

// Handle symlinks by checking their real path
try {
const realPath = await fs.realpath(absolute)
return normalizePath(realPath)
} catch (error) {
// For new files that don't exist yet, verify parent directory
const parentDir = path.dirname(absolute)
try {
const realParentPath = await fs.realpath(parentDir)
normalizePath(realParentPath)
return normalizePath(absolute)
} catch {
return normalizePath(absolute)
}
const resolvedRoot = await resolveRealOrNearestExistingPath(path.resolve(root))
const resolvedPath = await resolveRealOrNearestExistingPath(absolute)

if (!isPathWithinRoot(resolvedPath, resolvedRoot)) {
throw new Error(`Access denied: Path is outside the configured workspace root: ${requestedPath}`)
}

return resolvedPath
}

// ============================================================================
Expand Down
32 changes: 32 additions & 0 deletions src/renderer/src/store/__tests__/mcp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { BuiltinMCPServerNames, type MCPServer } from '@renderer/types'
import { describe, expect, it, vi } from 'vitest'

import { builtinMCPServers, initializeMCPServers, updateMCPServer } from '../mcp'

describe('MCP filesystem defaults', () => {
it('disables auto-approve for sensitive filesystem tools by default', () => {
const filesystemServer = builtinMCPServers.find((server) => server.name === BuiltinMCPServerNames.filesystem)

expect(filesystemServer?.disabledAutoApproveTools).toEqual(['write', 'edit', 'delete'])
})

it('backfills manual approval defaults for existing filesystem servers', () => {
const dispatch = vi.fn()
const existingFilesystemServer: MCPServer = {
id: 'filesystem-server',
name: BuiltinMCPServerNames.filesystem,
type: 'inMemory',
args: ['/tmp/workspace'],
isActive: true
}

initializeMCPServers([existingFilesystemServer], dispatch)

expect(dispatch).toHaveBeenCalledWith(
updateMCPServer({
...existingFilesystemServer,
disabledAutoApproveTools: ['write', 'edit', 'delete']
})
)
})
})
14 changes: 13 additions & 1 deletion src/renderer/src/store/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { createSlice, nanoid, type PayloadAction } from '@reduxjs/toolkit'
import { type BuiltinMCPServer, BuiltinMCPServerNames, type MCPConfig, type MCPServer } from '@renderer/types'

const logger = loggerService.withContext('Store:MCP')
const filesystemManualApprovalTools = ['write', 'edit', 'delete'] as const

export const initialState: MCPConfig = {
servers: [],
Expand Down Expand Up @@ -170,7 +171,8 @@ export const builtinMCPServers: BuiltinMCPServer[] = [
id: nanoid(),
name: BuiltinMCPServerNames.filesystem,
type: 'inMemory',
args: ['/Users/username/Desktop', '/path/to/other/allowed/dir'],
args: ['/Users/username/Desktop'],
disabledAutoApproveTools: [...filesystemManualApprovalTools],
shouldConfig: true,
isActive: false,
provider: 'CherryAI',
Expand Down Expand Up @@ -240,6 +242,16 @@ export const builtinMCPServers: BuiltinMCPServer[] = [
* @param dispatch Redux dispatch function
*/
export const initializeMCPServers = (existingServers: MCPServer[], dispatch: (action: any) => void): void => {
const existingFilesystemServer = existingServers.find((server) => server.name === BuiltinMCPServerNames.filesystem)
if (existingFilesystemServer && existingFilesystemServer.disabledAutoApproveTools === undefined) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: This backfill path appears to be unreachable in production. I couldn't find any runtime call site for , while the app reads MCP server configs directly from persisted Redux state. That means existing filesystem server entries keep the old auto-approve behavior after upgrade, so this only affects newly added servers. If that grandfathering is intentional, please document it explicitly.

dispatch(
updateMCPServer({
...existingFilesystemServer,
disabledAutoApproveTools: [...filesystemManualApprovalTools]
})
)
}

// Check if the existing servers already contain the built-in servers
const serverIds = new Set(existingServers.map((server) => server.name))

Expand Down
Loading