From 36aac34d45f4353ae6be787ba41e922cbe690691 Mon Sep 17 00:00:00 2001 From: Anna Tchijova Date: Mon, 31 Aug 2026 03:28:16 -0300 Subject: [PATCH 1/2] fix(filesystem): do not follow symlinks when reporting sizes in list_directory_with_sizes list_directory_with_sizes stat'd each entry with fs.stat, which follows symlinks. When an allowed directory contains a symlink that points outside the sandbox, the tool reported the size and mtime of the symlink target -- disclosing metadata about files outside the allowed directories, which the tool's own description says it must not touch. The sibling tools (list_directory, directory_tree) classify entries from the non-following Dirent, and read_file rejects out-of-sandbox targets via the realpath check in validatePath; only this tool followed symlinks. Use fs.lstat so a symlink is described by its own metadata, matching the other listing tools. Content was never exposed (reads go through validatePath); this closes the metadata leak. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01VWmkpCnuhCPKFPD2fexY1a --- src/filesystem/index.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/filesystem/index.ts b/src/filesystem/index.ts index 51ac523a66..b3006d25f1 100644 --- a/src/filesystem/index.ts +++ b/src/filesystem/index.ts @@ -489,7 +489,12 @@ server.registerTool( entries.map(async (entry) => { const entryPath = path.join(validPath, entry.name); try { - const stats = await fs.stat(entryPath); + // Use lstat so a symlink is described by its own metadata rather than its + // target's. fs.stat follows symlinks, which would report the size/mtime of a + // file outside the allowed directories when an allowed directory contains a + // symlink pointing out of the sandbox. This matches list_directory and + // directory_tree, which classify entries from the (non-following) Dirent. + const stats = await fs.lstat(entryPath); return { name: entry.name, isDirectory: entry.isDirectory(), From dc2ad929a033c4b63823a61e48b21dcc5a7cc56f Mon Sep 17 00:00:00 2001 From: Anna Tchijova Date: Tue, 1 Sep 2026 11:37:14 -0300 Subject: [PATCH 2/2] test(filesystem): assert list_directory_with_sizes does not follow symlinks out of sandbox --- .../list-directory-with-sizes.symlink.test.ts | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 src/filesystem/__tests__/list-directory-with-sizes.symlink.test.ts diff --git a/src/filesystem/__tests__/list-directory-with-sizes.symlink.test.ts b/src/filesystem/__tests__/list-directory-with-sizes.symlink.test.ts new file mode 100644 index 0000000000..032e9af201 --- /dev/null +++ b/src/filesystem/__tests__/list-directory-with-sizes.symlink.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as os from 'os'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; + +// Regression test: list_directory_with_sizes must not follow a symlink that points +// outside the allowed directories (it previously used fs.stat and leaked the target's +// size/mtime). Place under src/filesystem/__tests__/ and run after `npm run build`. +describe('list_directory_with_sizes symlink confinement', () => { + let client: Client; + let transport: StdioClientTransport; + let allowedDir: string; + let outsideDir: string; + + beforeEach(async () => { + allowedDir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-fs-allowed-'))); + outsideDir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-fs-outside-'))); + + // A distinctively large file OUTSIDE the sandbox (1 MiB -> "1.00 MB" via formatSize). + await fs.writeFile(path.join(outsideDir, 'secret.bin'), Buffer.alloc(1024 * 1024)); + // A symlink INSIDE the allowed dir pointing at it. + await fs.symlink(path.join(outsideDir, 'secret.bin'), path.join(allowedDir, 'leak_link')); + await fs.writeFile(path.join(allowedDir, 'normal.txt'), 'hello'); + + const serverPath = path.resolve(__dirname, '../dist/index.js'); + transport = new StdioClientTransport({ command: 'node', args: [serverPath, allowedDir] }); + client = new Client({ name: 'list-sizes-symlink-test', version: '1.0.0' }, { capabilities: {} }); + await client.connect(transport); + }); + + afterEach(async () => { + await client?.close(); + await fs.rm(allowedDir, { recursive: true, force: true }); + await fs.rm(outsideDir, { recursive: true, force: true }); + }); + + it('does not report the out-of-sandbox target size for a symlink', async () => { + const result = await client.callTool({ + name: 'list_directory_with_sizes', + arguments: { path: allowedDir }, + }); + const text = (result.structuredContent as { content: string }).content; + + // The link is still listed... + expect(text).toContain('leak_link'); + // ...but its target's size must not leak. With fs.stat it showed "1.00 MB"; + // with fs.lstat it shows the link's own (tiny) size. + expect(text).not.toContain('MB'); + }); +});