Skip to content

Commit 16c959e

Browse files
JonoGittyalemuenchenclaude
committed
fix(filesystem): also apply FS_SEARCH_EXCLUDE_PREFIXES to directory_tree
The README documented FS_SEARCH_EXCLUDE_PREFIXES as guarding both search_files and directory_tree, but the containment check lived only inside searchFilesWithValidation -- so directory_tree fell back to walking an excluded provider-backed root anyway. Docs and behaviour disagreed; caught by @alemuenchen during his live CloudStorage test. - Extract the check into assertSearchPathNotExcluded(rootPath, opName), exported from lib.ts so both call sites share the same containment rule (boundary-aware via isPathWithinAllowedDirectories). - Wire it into the directory_tree handler in index.ts, before buildTree. - Add a unit test on the shared helper for the customised opName. Behavioural note: search_files' error wording is unchanged ("Recursive search is disabled..."); directory_tree uses the same template with "directory_tree is disabled...". Co-authored-by: Alessandro <alemuenchen@users.noreply.github.com> Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent ed1dbcc commit 16c959e

3 files changed

Lines changed: 39 additions & 21 deletions

File tree

‎src/filesystem/__tests__/4162-timeouts-and-caps.test.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,16 @@ describe('issue #4162: timeouts + max-visited cap', () => {
244244
const out = await searchFilesWithValidation(sibling, '**/*', ALLOWED);
245245
expect(out.length).toBeGreaterThanOrEqual(0); // did not throw
246246
});
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+
});
247257
});
248258

249259
describe('env-var validation', () => {

‎src/filesystem/index.ts‎

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
// #4162: timeout + cap primitives reused by directory_tree
3030
withFsTimeout,
3131
FS_SEARCH_MAX_VISITED,
32+
assertSearchPathNotExcluded,
3233
} from './lib.js';
3334

3435
// Command line argument parsing
@@ -551,14 +552,16 @@ server.registerTool(
551552
}
552553
const rootPath = args.path;
553554

554-
// #4162: same defensive shape as searchFilesWithValidation -- bound the
555-
// recursion and timeout each readdir so a lazy provider-backed subtree
556-
// can't keep the server busy indefinitely. validatePath already wraps
557-
// fs.realpath via withFsTimeout in lib.ts, so only readdir + the entry
558-
// counter need handling here. We throw immediately when the cap is hit
559-
// rather than threading an `aborted` flag through unwinding recursion --
560-
// no caller wants a partial tree, and the immediate throw makes that
561-
// contract obvious to future refactors.
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');
562565
let visited = 0;
563566

564567
async function buildTree(currentPath: string, excludePatterns: string[] = []): Promise<TreeEntry[]> {

‎src/filesystem/lib.ts‎

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -463,25 +463,30 @@ export async function headFile(filePath: string, numLines: number): Promise<stri
463463
}
464464
}
465465

466+
// #4162: refuse recursive operations outright on user-configured slow roots.
467+
// Exported so both searchFilesWithValidation here and the directory_tree handler
468+
// in index.ts share the same containment check -- containment reuses
469+
// isPathWithinAllowedDirectories so it is boundary-aware: a prefix cannot match
470+
// an unrelated sibling that merely shares a string head.
471+
export function assertSearchPathNotExcluded(rootPath: string, opName: string): void {
472+
if (FS_SEARCH_EXCLUDE_PREFIXES.length === 0) return;
473+
const normalizedRoot = normalizePath(path.resolve(rootPath));
474+
if (isPathWithinAllowedDirectories(normalizedRoot, FS_SEARCH_EXCLUDE_PREFIXES)) {
475+
throw new Error(
476+
`${opName} is disabled for this path by FS_SEARCH_EXCLUDE_PREFIXES: ${rootPath}. ` +
477+
`Recursive operations over lazy provider-backed trees (macOS CloudStorage / FileProvider) ` +
478+
`are unreliable; use list_directory on specific subfolders, or narrow the search root.`
479+
);
480+
}
481+
}
482+
466483
export async function searchFilesWithValidation(
467484
rootPath: string,
468485
pattern: string,
469486
allowedDirectories: string[],
470487
options: SearchOptions = {}
471488
): Promise<string[]> {
472-
// #4162: refuse recursive search outright on user-configured slow roots.
473-
// Containment reuses isPathWithinAllowedDirectories so it is boundary-aware:
474-
// a prefix cannot match an unrelated sibling that merely shares a string head.
475-
if (FS_SEARCH_EXCLUDE_PREFIXES.length > 0) {
476-
const normalizedRoot = normalizePath(path.resolve(rootPath));
477-
if (isPathWithinAllowedDirectories(normalizedRoot, FS_SEARCH_EXCLUDE_PREFIXES)) {
478-
throw new Error(
479-
`Recursive search is disabled for this path by FS_SEARCH_EXCLUDE_PREFIXES: ${rootPath}. ` +
480-
`Recursive search over lazy provider-backed trees (macOS CloudStorage / FileProvider) ` +
481-
`is unreliable; use list_directory on specific subfolders, or narrow the search root.`
482-
);
483-
}
484-
}
489+
assertSearchPathNotExcluded(rootPath, 'Recursive search');
485490

486491
const { excludePatterns = [] } = options;
487492
const results: string[] = [];

0 commit comments

Comments
 (0)