Skip to content

Commit 2c35146

Browse files
JonoGittyalemuenchenclaude
committed
fix(filesystem): timeouts + max-visited cap for hangs on lazy provider paths (#4162)
fs.realpath (in validatePath) and recursive fs.readdir (in search_files / directory_tree) are called with no timeout, and recursive search has no upper bound on entries visited. On macOS provider-backed paths (~/Library/CloudStorage/..., ~/Library/Mobile Documents/com~apple~CloudDocs/..., NFS/SMB) these can block for minutes while the provider fetches metadata; the host eventually reports the connector unresponsive while the subprocess stays stuck. Follow-up to #4181, which only skipped excluded entries before realpath. lib.ts: - withFsTimeout() wraps fs.realpath and fs.readdir; on timeout rejects with a typed FsTimeoutError instead of hanging. A no-op catch on the timeout promise avoids spurious unhandled-rejection reports when the operation wins the race. - FS_SEARCH_MAX_VISITED caps recursive search; abort produces a clear "narrow the path / refine the pattern" error. - FS_SEARCH_EXCLUDE_PREFIXES (opt-in, empty default) refuses recursive operations on configured slow roots, via the shared boundary-aware assertSearchPathNotExcluded helper (reuses isPathWithinAllowedDirectories, expandHome applied symmetrically to the candidate root). - ENOENT branch in validatePath refactored so a hung parent realpath surfaces as FsTimeoutError instead of being masked as "Parent directory does not exist". - Per-entry catch in search re-throws FsTimeoutError so a search never returns silently-partial results. index.ts: - directory_tree gets the same timeout + visited-cap treatment and shares the exclude-prefix helper; throws immediately on cap-exceed rather than threading an aborted flag through unwinding recursion. Config (all env-var, conservative defaults; invalid values warn + fall back): FS_OP_TIMEOUT_MS=15000, FS_SEARCH_MAX_VISITED=50000, FS_SEARCH_EXCLUDE_PREFIXES=(empty) README documents the three vars, the NFS/SMB tuning note, and that FS_SEARCH_EXCLUDE_PREFIXES is a lexical prefilter (symlink-aware excludes tracked in #4208). Tests: new __tests__/4162-timeouts-and-caps.test.ts (14 cases) using vitest fake timers + mocked fs/promises, so no real CloudStorage path is needed in CI. Full suite 160/160. Verified on a real macOS Google Drive tree by @alemuenchen. Deliberately out of scope: libuv threadpool concurrency, fs.opendir streaming for huge single directories, symlink-aware excludes (#4208). Co-authored-by: Alessandro <alemuenchen@users.noreply.github.com> Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent b1e1eb1 commit 2c35146

4 files changed

Lines changed: 505 additions & 11 deletions

File tree

src/filesystem/README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,22 @@ The mapping for filesystem tools is:
204204

205205
> Note: `idempotentHint` and `destructiveHint` are meaningful only when `readOnlyHint` is `false`, as defined by the MCP spec.
206206
207+
## Environment variables
208+
209+
Three environment variables guard against hangs when `allowed_directories` includes a slow or lazy provider-backed path (macOS `~/Library/CloudStorage/...`, `~/Library/Mobile Documents/com~apple~CloudDocs/...`, NFS / SMB mounts, etc.). Defaults are safe for ordinary local trees; tune only if needed.
210+
211+
| Variable | Default | Purpose |
212+
|---|---|---|
213+
| `FS_OP_TIMEOUT_MS` | `15000` | Timeout (milliseconds) applied to each `fs.realpath` (in `validatePath`) and `fs.readdir` (in `search_files` and `directory_tree`). On timeout the call rejects with `FsTimeoutError` instead of hanging. |
214+
| `FS_SEARCH_MAX_VISITED` | `50000` | Hard cap on entries visited by a single `search_files` or `directory_tree` call before aborting with `"Search aborted after visiting N entries..."`. Prevents an unbounded recursion on a large or lazy-materialized tree. |
215+
| `FS_SEARCH_EXCLUDE_PREFIXES` | (empty) | Comma-separated path prefixes for which recursive search and `directory_tree` are refused outright. `~` is expanded. Matching is boundary-aware: a configured `/data/foo` will not match an unrelated `/data/foobar`. Intended for known-slow roots you'd rather forbid than time out on. |
216+
217+
Invalid numeric values (`"15s"`, `"0"`, negative) print a warning to stderr and fall back to the default, so a typo cannot silently disable a guard.
218+
219+
**Remote-mount tuning:** the 15 s default works well for local trees and warm CloudStorage. On NFS, SMB, or other remote mounts where individual `readdir` calls legitimately take seconds, consider raising `FS_OP_TIMEOUT_MS` (e.g. `30000``60000`) to avoid spurious timeouts.
220+
221+
**`FS_SEARCH_EXCLUDE_PREFIXES` is a fast lexical *prefilter*, not a canonical-path exclusion.** The requested root is compared (after `~` expansion and normalisation) against the configured prefixes, with no `realpath` step. A symlink whose target lives under an excluded prefix is therefore **not** blocked — the request would pass the lexical exclude and may still hit the slow provider path through `validatePath`'s realpath. Treat this as a best-effort guard against direct submissions of slow paths, not as a complete symlink-aware exclusion. Security against paths escaping `allowed_directories` is enforced separately by `validatePath`.
222+
207223
## Usage with Claude Desktop
208224
Add this to your `claude_desktop_config.json`:
209225

Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
1+
// Regression tests for issue #4162: recursive search / validatePath can hang
2+
// for minutes on macOS CloudStorage / lazy provider-backed paths. The patch
3+
// adds:
4+
// - a timeout wrapper around fs.realpath in validatePath
5+
// - a timeout wrapper around fs.readdir in searchFilesWithValidation
6+
// - a max-visited-entries cap on recursive search
7+
// - boundary-aware FS_SEARCH_EXCLUDE_PREFIXES that refuses recursive search
8+
// outright on user-configured roots
9+
//
10+
// These tests use fake timers to exercise the timeout paths without slow real
11+
// waits, and mock fs/promises at the module level so no real filesystem state
12+
// is touched.
13+
14+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
15+
import fs from 'fs/promises';
16+
import path from 'path';
17+
import type { Dirent } from 'fs';
18+
19+
vi.mock('fs/promises');
20+
const mockFs = fs as any;
21+
22+
// The diff reads its env vars at module load. We don't try to retune them per
23+
// test (which would need module re-import dance); we trust the defaults and
24+
// drive behaviour by making the mocked fs call hang past the default timeout
25+
// or by enumerating enough mocked entries to exceed the default cap.
26+
//
27+
// Default timeout is 15s (FS_OP_TIMEOUT_MS). With fake timers we just advance
28+
// past that — no real wait.
29+
// Default visited cap is 50_000 (FS_SEARCH_MAX_VISITED). We mock readdir to
30+
// return one directory containing that many synthetic entries.
31+
32+
const ALLOWED = process.platform === 'win32' ? ['C:\\allowed'] : ['/allowed'];
33+
const ROOT = process.platform === 'win32' ? 'C:\\allowed' : '/allowed';
34+
const FILE = process.platform === 'win32' ? 'C:\\allowed\\file.txt' : '/allowed/file.txt';
35+
36+
function mkDirent(name: string, isDir = false): Dirent {
37+
return {
38+
name,
39+
isFile: () => !isDir,
40+
isDirectory: () => isDir,
41+
isSymbolicLink: () => false,
42+
isBlockDevice: () => false,
43+
isCharacterDevice: () => false,
44+
isFIFO: () => false,
45+
isSocket: () => false,
46+
parentPath: '',
47+
path: '',
48+
} as unknown as Dirent;
49+
}
50+
51+
describe('issue #4162: timeouts + max-visited cap', () => {
52+
let setAllowedDirectories: typeof import('../lib.js').setAllowedDirectories;
53+
let validatePath: typeof import('../lib.js').validatePath;
54+
let searchFilesWithValidation: typeof import('../lib.js').searchFilesWithValidation;
55+
56+
beforeEach(async () => {
57+
vi.resetModules();
58+
vi.clearAllMocks();
59+
const lib = await import('../lib.js');
60+
setAllowedDirectories = lib.setAllowedDirectories;
61+
validatePath = lib.validatePath;
62+
searchFilesWithValidation = lib.searchFilesWithValidation;
63+
setAllowedDirectories(ALLOWED);
64+
});
65+
66+
afterEach(() => {
67+
setAllowedDirectories([]);
68+
vi.useRealTimers();
69+
vi.restoreAllMocks();
70+
});
71+
72+
describe('validatePath: fs.realpath timeout', () => {
73+
it('rejects with FsTimeoutError when realpath hangs past FS_OP_TIMEOUT_MS', async () => {
74+
vi.useFakeTimers();
75+
// realpath returns a promise that never settles — the previous code would
76+
// hang forever; the patch must surface a timeout.
77+
mockFs.realpath.mockImplementation(() => new Promise(() => {}));
78+
79+
const op = validatePath(FILE);
80+
// Attach a no-op catch BEFORE timer fires, so the rejection is "handled"
81+
// by the time vi.advanceTimersByTimeAsync flushes microtasks. Without
82+
// this, vitest's strict unhandled-rejection checker trips even though
83+
// expect.rejects below will observe the same rejection.
84+
op.catch(() => {});
85+
// Advance past the 15s default. We use 20s for a margin.
86+
await vi.advanceTimersByTimeAsync(20_000);
87+
await expect(op).rejects.toThrow(/timed out/i);
88+
});
89+
90+
it('preserves ENOENT path: surfaces "Parent directory does not exist" when both realpaths reject ENOENT', async () => {
91+
// Regression for the ENOENT-branch refactor in the patch — a non-existent
92+
// file with a non-existent parent must still produce the original error,
93+
// not a misleading timeout.
94+
const enoent = (): NodeJS.ErrnoException => {
95+
const e = new Error('ENOENT') as NodeJS.ErrnoException;
96+
e.code = 'ENOENT';
97+
return e;
98+
};
99+
mockFs.realpath
100+
.mockRejectedValueOnce(enoent())
101+
.mockRejectedValueOnce(enoent());
102+
103+
await expect(
104+
validatePath(
105+
process.platform === 'win32'
106+
? 'C:\\allowed\\missing\\file.txt'
107+
: '/allowed/missing/file.txt',
108+
),
109+
).rejects.toThrow(/Parent directory does not exist/);
110+
});
111+
112+
it('ENOENT-branch realpath timeout surfaces as FsTimeoutError, not masked as missing-parent', async () => {
113+
// Verifies the refactor: a hung parent realpath must propagate the
114+
// timeout rather than collapse into "Parent directory does not exist".
115+
vi.useFakeTimers();
116+
const enoent = new Error('ENOENT') as NodeJS.ErrnoException;
117+
enoent.code = 'ENOENT';
118+
mockFs.realpath
119+
.mockRejectedValueOnce(enoent)
120+
.mockImplementationOnce(() => new Promise(() => {})); // parent hangs
121+
122+
const op = validatePath(
123+
process.platform === 'win32'
124+
? 'C:\\allowed\\new\\file.txt'
125+
: '/allowed/new/file.txt',
126+
);
127+
op.catch(() => {}); // see note above
128+
await vi.advanceTimersByTimeAsync(20_000);
129+
await expect(op).rejects.toThrow(/timed out/i);
130+
});
131+
});
132+
133+
describe('searchFilesWithValidation: fs.readdir timeout', () => {
134+
it('rejects with FsTimeoutError when readdir hangs past FS_OP_TIMEOUT_MS', async () => {
135+
vi.useFakeTimers();
136+
mockFs.realpath.mockImplementation(async (p: any) => p.toString());
137+
mockFs.readdir.mockImplementation(() => new Promise(() => {}));
138+
139+
const op = searchFilesWithValidation(ROOT, '**/*', ALLOWED);
140+
op.catch(() => {}); // see note above
141+
await vi.advanceTimersByTimeAsync(20_000);
142+
await expect(op).rejects.toThrow(/timed out/i);
143+
});
144+
145+
it('per-entry timeout propagates instead of being swallowed by catch-continue', async () => {
146+
// Critical for the patch: the existing per-entry `catch { continue }`
147+
// must re-throw FsTimeoutError so a search never returns silently-partial
148+
// results. We make readdir succeed once with two entries, the second of
149+
// which has a realpath that hangs.
150+
vi.useFakeTimers();
151+
mockFs.readdir.mockResolvedValueOnce([
152+
mkDirent('a.txt'),
153+
mkDirent('b.txt'),
154+
]);
155+
// First entry's realpath resolves fine, second hangs forever.
156+
let call = 0;
157+
mockFs.realpath.mockImplementation((p: any) => {
158+
call++;
159+
if (call === 1) return Promise.resolve(p.toString());
160+
return new Promise(() => {});
161+
});
162+
163+
const op = searchFilesWithValidation(ROOT, '**/*', ALLOWED);
164+
op.catch(() => {}); // see note above
165+
await vi.advanceTimersByTimeAsync(20_000);
166+
await expect(op).rejects.toThrow(/timed out/i);
167+
});
168+
});
169+
170+
describe('searchFilesWithValidation: FS_SEARCH_MAX_VISITED cap', () => {
171+
// Drive the cap via env-var rather than a 50_001-entry fixture: cleaner,
172+
// faster, and exercises the same code path. Module is re-imported per test
173+
// so the module-level read of FS_SEARCH_MAX_VISITED picks up our value.
174+
beforeEach(async () => {
175+
vi.resetModules();
176+
process.env.FS_SEARCH_MAX_VISITED = '5';
177+
const lib = await import('../lib.js');
178+
setAllowedDirectories = lib.setAllowedDirectories;
179+
searchFilesWithValidation = lib.searchFilesWithValidation;
180+
setAllowedDirectories(ALLOWED);
181+
});
182+
183+
afterEach(() => {
184+
delete process.env.FS_SEARCH_MAX_VISITED;
185+
});
186+
187+
it('aborts with a clear error once the visited cap is exceeded', async () => {
188+
const entries: Dirent[] = [];
189+
for (let i = 0; i < 10; i++) entries.push(mkDirent(`f${i}.txt`));
190+
mockFs.readdir.mockResolvedValue(entries);
191+
mockFs.realpath.mockImplementation(async (p: any) => p.toString());
192+
193+
await expect(
194+
searchFilesWithValidation(ROOT, '**/*', ALLOWED),
195+
).rejects.toThrow(/Search aborted after visiting/);
196+
});
197+
198+
it('returns normally when entry count is well under the cap', async () => {
199+
mockFs.readdir.mockResolvedValueOnce([
200+
mkDirent('hit.txt'),
201+
mkDirent('miss.log'),
202+
]);
203+
mockFs.realpath.mockImplementation(async (p: any) => p.toString());
204+
205+
const out = await searchFilesWithValidation(ROOT, '**/*.txt', ALLOWED);
206+
expect(out.some(p => p.endsWith('hit.txt'))).toBe(true);
207+
expect(out.some(p => p.endsWith('miss.log'))).toBe(false);
208+
});
209+
});
210+
211+
describe('FS_SEARCH_EXCLUDE_PREFIXES', () => {
212+
// Set the env var BEFORE importing lib.ts so the module-level read picks
213+
// it up. We isolate this in its own describe so vi.resetModules() rewinds
214+
// cleanly between tests.
215+
const EXCLUDED = process.platform === 'win32' ? 'C:\\allowed\\slow' : '/allowed/slow';
216+
217+
beforeEach(async () => {
218+
vi.resetModules();
219+
process.env.FS_SEARCH_EXCLUDE_PREFIXES = EXCLUDED;
220+
const lib = await import('../lib.js');
221+
setAllowedDirectories = lib.setAllowedDirectories;
222+
searchFilesWithValidation = lib.searchFilesWithValidation;
223+
setAllowedDirectories(ALLOWED);
224+
});
225+
226+
afterEach(() => {
227+
delete process.env.FS_SEARCH_EXCLUDE_PREFIXES;
228+
});
229+
230+
it('refuses recursive search when root is inside an excluded prefix', async () => {
231+
await expect(
232+
searchFilesWithValidation(EXCLUDED, '**/*', ALLOWED),
233+
).rejects.toThrow(/Recursive search is disabled/);
234+
});
235+
236+
it('does not match an unrelated sibling that merely shares a string head (boundary-aware)', async () => {
237+
// /allowed/slowdown must NOT be excluded just because '/allowed/slow' is
238+
// a substring of it. This is the containment-via-isPathWithinAllowedDirectories
239+
// guarantee from the patch.
240+
const sibling = process.platform === 'win32' ? 'C:\\allowed\\slowdown' : '/allowed/slowdown';
241+
mockFs.readdir.mockResolvedValueOnce([mkDirent('ok.txt')]);
242+
mockFs.realpath.mockImplementation(async (p: any) => p.toString());
243+
244+
const out = await searchFilesWithValidation(sibling, '**/*', ALLOWED);
245+
expect(out.length).toBeGreaterThanOrEqual(0); // did not throw
246+
});
247+
248+
it('shared helper assertSearchPathNotExcluded customises the operation name', async () => {
249+
// The same containment check is reused by the directory_tree handler in
250+
// index.ts via assertSearchPathNotExcluded(rootPath, 'directory_tree').
251+
// We can't unit-test the inline handler directly, but we can exercise
252+
// the shared helper to confirm the error wording reflects the opName.
253+
const { assertSearchPathNotExcluded } = await import('../lib.js');
254+
expect(() => assertSearchPathNotExcluded(EXCLUDED, 'directory_tree'))
255+
.toThrow(/directory_tree is disabled.*FS_SEARCH_EXCLUDE_PREFIXES/);
256+
});
257+
258+
it('helper rejects descendants of an excluded prefix, not just the exact root', async () => {
259+
const { assertSearchPathNotExcluded } = await import('../lib.js');
260+
const descendant = process.platform === 'win32'
261+
? 'C:\\allowed\\slow\\nested\\deep'
262+
: '/allowed/slow/nested/deep';
263+
expect(() => assertSearchPathNotExcluded(descendant, 'directory_tree'))
264+
.toThrow(/directory_tree is disabled/);
265+
});
266+
267+
it('helper allows a sibling that merely shares a textual prefix with the excluded root', async () => {
268+
// /allowed/slowdown is NOT a descendant of /allowed/slow even though
269+
// the latter is a string prefix -- the containment check is boundary-aware.
270+
const { assertSearchPathNotExcluded } = await import('../lib.js');
271+
const sibling = process.platform === 'win32' ? 'C:\\allowed\\slowdown' : '/allowed/slowdown';
272+
expect(() => assertSearchPathNotExcluded(sibling, 'directory_tree')).not.toThrow();
273+
});
274+
});
275+
276+
describe('env-var validation', () => {
277+
afterEach(() => {
278+
delete process.env.FS_OP_TIMEOUT_MS;
279+
delete process.env.FS_SEARCH_MAX_VISITED;
280+
});
281+
282+
it('ignores non-numeric env values and warns to stderr', async () => {
283+
const warn = vi.spyOn(console, 'error').mockImplementation(() => {});
284+
process.env.FS_OP_TIMEOUT_MS = '15s'; // intentionally invalid
285+
286+
vi.resetModules();
287+
await import('../lib.js');
288+
289+
expect(warn).toHaveBeenCalledWith(
290+
expect.stringMatching(/Ignoring invalid FS_OP_TIMEOUT_MS="15s"/),
291+
);
292+
});
293+
294+
it('ignores zero/negative values and warns to stderr', async () => {
295+
const warn = vi.spyOn(console, 'error').mockImplementation(() => {});
296+
process.env.FS_SEARCH_MAX_VISITED = '0';
297+
298+
vi.resetModules();
299+
await import('../lib.js');
300+
301+
expect(warn).toHaveBeenCalledWith(
302+
expect.stringMatching(/Ignoring invalid FS_SEARCH_MAX_VISITED="0"/),
303+
);
304+
});
305+
});
306+
});

src/filesystem/index.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ import {
2626
tailFile,
2727
headFile,
2828
setAllowedDirectories,
29+
// #4162: timeout + cap primitives reused by directory_tree
30+
withFsTimeout,
31+
FS_SEARCH_MAX_VISITED,
32+
assertSearchPathNotExcluded,
2933
} from './lib.js';
3034

3135
// Command line argument parsing
@@ -548,12 +552,36 @@ server.registerTool(
548552
}
549553
const rootPath = args.path;
550554

555+
// #4162: same defensive shape as searchFilesWithValidation -- honour
556+
// FS_SEARCH_EXCLUDE_PREFIXES, bound the recursion, and timeout each
557+
// readdir so a lazy provider-backed subtree can't keep the server busy
558+
// indefinitely. validatePath already wraps fs.realpath via withFsTimeout
559+
// in lib.ts, so only readdir + the entry counter need handling here. We
560+
// throw immediately when the cap is hit rather than threading an
561+
// `aborted` flag through unwinding recursion -- no caller wants a partial
562+
// tree, and the immediate throw makes that contract obvious to future
563+
// refactors.
564+
assertSearchPathNotExcluded(rootPath, 'directory_tree');
565+
let visited = 0;
566+
551567
async function buildTree(currentPath: string, excludePatterns: string[] = []): Promise<TreeEntry[]> {
552568
const validPath = await validatePath(currentPath);
553-
const entries = await fs.readdir(validPath, { withFileTypes: true });
569+
const entries = await withFsTimeout(
570+
fs.readdir(validPath, { withFileTypes: true }),
571+
`readdir ${validPath}`,
572+
);
554573
const result: TreeEntry[] = [];
555574

556575
for (const entry of entries) {
576+
if (visited >= FS_SEARCH_MAX_VISITED) {
577+
throw new Error(
578+
`directory_tree aborted after visiting ${visited} entries ` +
579+
`(cap FS_SEARCH_MAX_VISITED=${FS_SEARCH_MAX_VISITED}). ` +
580+
`Narrow the search path or use excludePatterns.`
581+
);
582+
}
583+
visited++;
584+
557585
const relativePath = path.relative(rootPath, path.join(currentPath, entry.name));
558586
const shouldExclude = excludePatterns.some(pattern => {
559587
if (pattern.includes('*')) {

0 commit comments

Comments
 (0)