From c74c5271b33be57c75ebcaf2903b447f5c6ef35e Mon Sep 17 00:00:00 2001 From: umutcagand Date: Mon, 24 Aug 2026 00:30:06 +0300 Subject: [PATCH 1/6] chore: refresh fix/sdk-list-directory-path-boundary onto current main --- common/src/testing/mocks/filesystem.ts | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/common/src/testing/mocks/filesystem.ts b/common/src/testing/mocks/filesystem.ts index 6c9703622e..bf2af29bde 100644 --- a/common/src/testing/mocks/filesystem.ts +++ b/common/src/testing/mocks/filesystem.ts @@ -2,7 +2,7 @@ import { mock } from 'bun:test' import type { CodebuffFileSystem } from '../../types/filesystem' import type { Mock } from 'bun:test' -import type { PathLike , Stats } from 'node:fs' +import type { PathLike, Stats } from 'node:fs' export interface CreateMockFsOptions { files?: Record @@ -14,6 +14,7 @@ export interface CreateMockFsOptions { path: string, options?: { recursive?: boolean }, ) => Promise + realpathImpl?: (path: string) => Promise statImpl?: (path: string) => Promise } @@ -31,6 +32,7 @@ export interface MockFsWithMocks { options?: { recursive?: boolean }, ) => Promise > + realpath: Mock<(path: PathLike) => Promise> stat: Mock<(path: PathLike) => Promise> } @@ -43,6 +45,7 @@ export function createMockFs(options: CreateMockFsOptions = {}): MockFs { readdirImpl, writeFileImpl, mkdirImpl, + realpathImpl, statImpl, } = options @@ -79,6 +82,20 @@ export function createMockFs(options: CreateMockFsOptions = {}): MockFs { return undefined } + const defaultRealpath = async (path: PathLike): Promise => { + const pathStr = String(path) + const isKnownPath = + pathStr in writtenFiles || + pathStr in directories || + createdDirs.has(pathStr) + + if (!isKnownPath) { + throw new Error(`Path not found: ${pathStr}`) + } + + return pathStr + } + const defaultStat = async (path: PathLike): Promise => { const pathStr = String(path) const isFile = pathStr in writtenFiles @@ -134,6 +151,10 @@ export function createMockFs(options: CreateMockFsOptions = {}): MockFs { mkdirImpl(String(path), opts) : defaultMkdir + const realpathFn = realpathImpl + ? async (path: PathLike) => realpathImpl(String(path)) + : defaultRealpath + const statFn = statImpl ? async (path: PathLike) => statImpl(String(path)) : defaultStat @@ -143,6 +164,7 @@ export function createMockFs(options: CreateMockFsOptions = {}): MockFs { readdir: mock(readdirFn), writeFile: mock(writeFileFn), mkdir: mock(mkdirFn), + realpath: mock(realpathFn), stat: mock(statFn), } as unknown as MockFs } @@ -153,6 +175,7 @@ export function restoreMockFs(mockFs: MockFs): void { mocks.readdir.mockRestore() mocks.writeFile.mockRestore() mocks.mkdir.mockRestore() + mocks.realpath.mockRestore() mocks.stat.mockRestore() } @@ -162,5 +185,6 @@ export function clearMockFs(mockFs: MockFs): void { mocks.readdir.mockClear() mocks.writeFile.mockClear() mocks.mkdir.mockClear() + mocks.realpath.mockClear() mocks.stat.mockClear() } From efeaf4524285d164be590f8c1b9d9c6662b6eca6 Mon Sep 17 00:00:00 2001 From: umutcagand Date: Mon, 24 Aug 2026 00:30:07 +0300 Subject: [PATCH 2/6] chore: refresh fix/sdk-list-directory-path-boundary onto current main --- common/src/types/filesystem.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/common/src/types/filesystem.ts b/common/src/types/filesystem.ts index 6fa64e1168..4506b5c87e 100644 --- a/common/src/types/filesystem.ts +++ b/common/src/types/filesystem.ts @@ -6,5 +6,11 @@ import type fs from 'fs' */ export type CodebuffFileSystem = Pick< typeof fs.promises, - 'mkdir' | 'readdir' | 'readFile' | 'stat' | 'unlink' | 'writeFile' + | 'mkdir' + | 'readdir' + | 'readFile' + | 'realpath' + | 'stat' + | 'unlink' + | 'writeFile' > From 7b51be12222d2c22223e9e47aff9c9cbabeaed54 Mon Sep 17 00:00:00 2001 From: umutcagand Date: Mon, 24 Aug 2026 00:30:09 +0300 Subject: [PATCH 3/6] chore: refresh fix/sdk-list-directory-path-boundary onto current main --- sdk/src/__tests__/list-directory.test.ts | 158 +++++++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 sdk/src/__tests__/list-directory.test.ts diff --git a/sdk/src/__tests__/list-directory.test.ts b/sdk/src/__tests__/list-directory.test.ts new file mode 100644 index 0000000000..dd10ca9a5b --- /dev/null +++ b/sdk/src/__tests__/list-directory.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it, mock } from 'bun:test' + +import { listDirectory } from '../tools/list-directory' + +import type { CodebuffFileSystem } from '@codebuff/common/types/filesystem' +import type { Dirent, PathLike } from 'node:fs' + +const PROJECT_ROOT = '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/workspace/project' + +function createFs(realpaths: Record) { + const readdir = mock(async (_path: PathLike) => { + return [ + { + name: 'index.ts', + isDirectory: () => false, + isFile: () => true, + }, + ] as Dirent[] + }) + + const fs = { + realpath: mock(async (path: PathLike) => { + const pathString = String(path) + return realpaths[pathString] ?? pathString + }), + readdir, + } as unknown as CodebuffFileSystem + + return { fs, readdir } +} + +describe('listDirectory', () => { + it('allows listing the project root itself', async () => { + const { fs, readdir } = createFs({ + [PROJECT_ROOT]: PROJECT_ROOT, + }) + + const result = await listDirectory({ + directoryPath: '.', + projectPath: PROJECT_ROOT, + fs, + }) + + expect(result[0]).toEqual({ + type: 'json', + value: { + files: ['index.ts'], + directories: [], + path: '.', + }, + }) + expect(readdir).toHaveBeenCalledWith(PROJECT_ROOT, { + withFileTypes: true, + }) + }) + + it('lists a directory inside the project and preserves the requested path', async () => { + const { fs, readdir } = createFs({ + [PROJECT_ROOT]: PROJECT_ROOT, + [`${PROJECT_ROOT}/src`]: `${PROJECT_ROOT}/src`, + }) + + const result = await listDirectory({ + directoryPath: 'src', + projectPath: PROJECT_ROOT, + fs, + }) + + expect(result).toEqual([ + { + type: 'json', + value: { + files: ['index.ts'], + directories: [], + path: 'src', + }, + }, + ]) + expect(readdir).toHaveBeenCalledWith(`${PROJECT_ROOT}/src`, { + withFileTypes: true, + }) + }) + + it('rejects sibling paths that only share the project prefix', async () => { + const siblingPath = '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/workspace/project-evil' + const { fs, readdir } = createFs({ + [PROJECT_ROOT]: PROJECT_ROOT, + [siblingPath]: siblingPath, + }) + + const result = await listDirectory({ + directoryPath: '../project-evil', + projectPath: PROJECT_ROOT, + fs, + }) + + expect(result).toEqual([ + { + type: 'json', + value: { + errorMessage: + "Invalid path: Path '../project-evil' is outside the project directory.", + }, + }, + ]) + expect(readdir).not.toHaveBeenCalled() + }) + + it('rejects the project parent directory', async () => { + const parentPath = '/workspace' + const { fs, readdir } = createFs({ + [PROJECT_ROOT]: PROJECT_ROOT, + [parentPath]: parentPath, + }) + + const result = await listDirectory({ + directoryPath: '..', + projectPath: PROJECT_ROOT, + fs, + }) + + expect(result).toEqual([ + { + type: 'json', + value: { + errorMessage: + "Invalid path: Path '..' is outside the project directory.", + }, + }, + ]) + expect(readdir).not.toHaveBeenCalled() + }) + + it('rejects directories that escape through a symlink', async () => { + const symlinkPath = `${PROJECT_ROOT}/link` + const { fs, readdir } = createFs({ + [PROJECT_ROOT]: PROJECT_ROOT, + [symlinkPath]: '/outside', + }) + + const result = await listDirectory({ + directoryPath: 'link', + projectPath: PROJECT_ROOT, + fs, + }) + + expect(result).toEqual([ + { + type: 'json', + value: { + errorMessage: + "Invalid path: Path 'link' is outside the project directory.", + }, + }, + ]) + expect(readdir).not.toHaveBeenCalled() + }) +}) From 46046f2f0b4d3c52bfac37e326b116a36f0091e3 Mon Sep 17 00:00:00 2001 From: umutcagand Date: Mon, 24 Aug 2026 00:30:10 +0300 Subject: [PATCH 4/6] chore: refresh fix/sdk-list-directory-path-boundary onto current main --- sdk/src/tools/list-directory.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/sdk/src/tools/list-directory.ts b/sdk/src/tools/list-directory.ts index 3bf66fa968..53f12b660e 100644 --- a/sdk/src/tools/list-directory.ts +++ b/sdk/src/tools/list-directory.ts @@ -2,6 +2,7 @@ import * as path from 'path' import type { CodebuffToolOutput } from '@codebuff/common/tools/list' import type { CodebuffFileSystem } from '@codebuff/common/types/filesystem' +import { isPathInside } from '@codebuff/common/util/path' export async function listDirectory(params: { directoryPath: string @@ -11,9 +12,23 @@ export async function listDirectory(params: { const { directoryPath, projectPath, fs } = params try { - const resolvedPath = path.resolve(projectPath, directoryPath) + const projectRoot = path.resolve(projectPath) + const resolvedPath = path.resolve(projectRoot, directoryPath) + const realProjectRoot = await fs.realpath(projectRoot) + const realResolvedPath = await fs.realpath(resolvedPath) - const entries = await fs.readdir(resolvedPath, { + if (!isPathInside(realProjectRoot, realResolvedPath)) { + return [ + { + type: 'json', + value: { + errorMessage: `Invalid path: Path '${directoryPath}' is outside the project directory.`, + }, + }, + ] + } + + const entries = await fs.readdir(realResolvedPath, { withFileTypes: true, }) From b8caa3a4fa097d9072758703848e0a7868c8b755 Mon Sep 17 00:00:00 2001 From: umutcagand Date: Mon, 24 Aug 2026 07:36:15 +0300 Subject: [PATCH 5/6] test(sdk): make list-directory boundary cases portable --- sdk/src/__tests__/list-directory.test.ts | 47 ++++++++++++++++++++---- 1 file changed, 40 insertions(+), 7 deletions(-) diff --git a/sdk/src/__tests__/list-directory.test.ts b/sdk/src/__tests__/list-directory.test.ts index dd10ca9a5b..61f269b726 100644 --- a/sdk/src/__tests__/list-directory.test.ts +++ b/sdk/src/__tests__/list-directory.test.ts @@ -1,11 +1,13 @@ import { describe, expect, it, mock } from 'bun:test' +import path from 'path' + import { listDirectory } from '../tools/list-directory' import type { CodebuffFileSystem } from '@codebuff/common/types/filesystem' import type { Dirent, PathLike } from 'node:fs' -const PROJECT_ROOT = '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/workspace/project' +const PROJECT_ROOT = path.resolve('workspace', 'project') function createFs(realpaths: Record) { const readdir = mock(async (_path: PathLike) => { @@ -55,9 +57,10 @@ describe('listDirectory', () => { }) it('lists a directory inside the project and preserves the requested path', async () => { + const childPath = path.join(PROJECT_ROOT, 'src') const { fs, readdir } = createFs({ [PROJECT_ROOT]: PROJECT_ROOT, - [`${PROJECT_ROOT}/src`]: `${PROJECT_ROOT}/src`, + [childPath]: childPath, }) const result = await listDirectory({ @@ -76,13 +79,13 @@ describe('listDirectory', () => { }, }, ]) - expect(readdir).toHaveBeenCalledWith(`${PROJECT_ROOT}/src`, { + expect(readdir).toHaveBeenCalledWith(childPath, { withFileTypes: true, }) }) it('rejects sibling paths that only share the project prefix', async () => { - const siblingPath = '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/workspace/project-evil' + const siblingPath = path.resolve(PROJECT_ROOT, '..', 'project-evil') const { fs, readdir } = createFs({ [PROJECT_ROOT]: PROJECT_ROOT, [siblingPath]: siblingPath, @@ -107,7 +110,7 @@ describe('listDirectory', () => { }) it('rejects the project parent directory', async () => { - const parentPath = '/workspace' + const parentPath = path.dirname(PROJECT_ROOT) const { fs, readdir } = createFs({ [PROJECT_ROOT]: PROJECT_ROOT, [parentPath]: parentPath, @@ -132,10 +135,11 @@ describe('listDirectory', () => { }) it('rejects directories that escape through a symlink', async () => { - const symlinkPath = `${PROJECT_ROOT}/link` + const symlinkPath = path.join(PROJECT_ROOT, 'link') + const outsidePath = path.resolve(PROJECT_ROOT, '..', 'outside') const { fs, readdir } = createFs({ [PROJECT_ROOT]: PROJECT_ROOT, - [symlinkPath]: '/outside', + [symlinkPath]: outsidePath, }) const result = await listDirectory({ @@ -155,4 +159,33 @@ describe('listDirectory', () => { ]) expect(readdir).not.toHaveBeenCalled() }) + + it('allows a symlink that resolves inside the project', async () => { + const symlinkPath = path.join(PROJECT_ROOT, 'link') + const realTarget = path.join(PROJECT_ROOT, 'src') + const { fs, readdir } = createFs({ + [PROJECT_ROOT]: PROJECT_ROOT, + [symlinkPath]: realTarget, + }) + + const result = await listDirectory({ + directoryPath: 'link', + projectPath: PROJECT_ROOT, + fs, + }) + + expect(result).toEqual([ + { + type: 'json', + value: { + files: ['index.ts'], + directories: [], + path: 'link', + }, + }, + ]) + expect(readdir).toHaveBeenCalledWith(realTarget, { + withFileTypes: true, + }) + }) }) From cb8679373f9d04fca143d251c714c07f96ae6b22 Mon Sep 17 00:00:00 2001 From: umutcagand Date: Mon, 24 Aug 2026 20:12:07 +0300 Subject: [PATCH 6/6] test(sdk): cover missing list_directory targets --- sdk/src/__tests__/list-directory.test.ts | 33 ++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/sdk/src/__tests__/list-directory.test.ts b/sdk/src/__tests__/list-directory.test.ts index 61f269b726..5637a75b4c 100644 --- a/sdk/src/__tests__/list-directory.test.ts +++ b/sdk/src/__tests__/list-directory.test.ts @@ -84,6 +84,39 @@ describe('listDirectory', () => { }) }) + it('returns the normal list error when the requested directory is missing', async () => { + const missingPath = path.join(PROJECT_ROOT, 'missing') + const readdir = mock(async (_path: PathLike) => [] as Dirent[]) + const fs = { + realpath: mock(async (requestedPath: PathLike) => { + const requestedPathString = String(requestedPath) + if (requestedPathString === missingPath) { + throw new Error( + `ENOENT: no such file or directory, realpath '${missingPath}'`, + ) + } + return requestedPathString + }), + readdir, + } as unknown as CodebuffFileSystem + + const result = await listDirectory({ + directoryPath: 'missing', + projectPath: PROJECT_ROOT, + fs, + }) + + expect(result).toEqual([ + { + type: 'json', + value: { + errorMessage: `Failed to list directory: ENOENT: no such file or directory, realpath '${missingPath}'`, + }, + }, + ]) + expect(readdir).not.toHaveBeenCalled() + }) + it('rejects sibling paths that only share the project prefix', async () => { const siblingPath = path.resolve(PROJECT_ROOT, '..', 'project-evil') const { fs, readdir } = createFs({