diff --git a/src/server/__tests__/filesystem.test.ts b/src/server/__tests__/filesystem.test.ts index 0e2ffdc2..6c3d0c3a 100644 --- a/src/server/__tests__/filesystem.test.ts +++ b/src/server/__tests__/filesystem.test.ts @@ -5,6 +5,8 @@ import * as fsp from 'fs/promises' import * as os from 'os' import * as path from 'path' import { handleFilesystemRoute } from '../api/filesystem.js' +import { clearFilesystemAccessRootsForTests } from '../services/filesystemAccessRoots.js' +import { getRepositoryContext } from '../services/repositoryLaunchService.js' const cleanupDirs = new Set() @@ -21,6 +23,7 @@ afterEach(async () => { await fsp.rm(dir, { recursive: true, force: true }) } cleanupDirs.clear() + clearFilesystemAccessRootsForTests() }) function git(cwd: string, ...args: string[]): string { @@ -30,6 +33,34 @@ function git(cwd: string, ...args: string[]): string { }) } +function isWithinPath(targetPath: string, rootPath: string): boolean { + const target = path.resolve(targetPath) + const root = path.resolve(rootPath) + return target === root || target.startsWith(`${root}${path.sep}`) +} + +async function makeExternalFixtureDir(): Promise { + const candidates = ['/var/tmp', '/private/var/tmp', '/Users/Shared'] + + for (const baseDir of candidates) { + try { + const stat = await fsp.stat(baseDir) + if (!stat.isDirectory()) continue + const fixtureDir = await fsp.mkdtemp(path.join(baseDir, 'claude-filesystem-test-')) + const isDefaultAllowed = + isWithinPath(fixtureDir, os.homedir()) || + isWithinPath(fixtureDir, '/tmp') || + (process.platform === 'darwin' && isWithinPath(fixtureDir, '/private/tmp')) + if (!isDefaultAllowed) return fixtureDir + await fsp.rm(fixtureDir, { recursive: true, force: true }) + } catch { + // Try the next common writable system directory. + } + } + + return null +} + describe('filesystem API', () => { it('allows browsing a directory under the user home directory', async () => { const homeFixtureDir = await fsp.mkdtemp(path.join(os.homedir(), 'claude-filesystem-test-')) @@ -49,6 +80,37 @@ describe('filesystem API', () => { expect(body.entries.some((entry) => entry.name === 'note.txt')).toBe(true) }) + it('allows browsing a selected workspace outside the default home/tmp roots', async () => { + const externalFixtureDir = await makeExternalFixtureDir() + if (!externalFixtureDir) return + + cleanupDirs.add(externalFixtureDir) + await fsp.writeFile(path.join(externalFixtureDir, 'note.txt'), 'hello') + + const deniedRes = await handleFilesystemRoute( + '/api/filesystem/browse', + makeUrl('/api/filesystem/browse', { + path: externalFixtureDir, + includeFiles: 'true', + }), + ) + expect(deniedRes.status).toBe(403) + + await getRepositoryContext(externalFixtureDir) + + const res = await handleFilesystemRoute( + '/api/filesystem/browse', + makeUrl('/api/filesystem/browse', { + path: externalFixtureDir, + includeFiles: 'true', + }), + ) + + expect(res.status).toBe(200) + const body = await res.json() as { entries: Array<{ name: string }> } + expect(body.entries.some((entry) => entry.name === 'note.txt')).toBe(true) + }) + it('fuzzy searches files and directories below the selected root', async () => { const homeFixtureDir = await fsp.mkdtemp(path.join(os.homedir(), 'claude-filesystem-test-')) cleanupDirs.add(homeFixtureDir) diff --git a/src/server/api/filesystem.ts b/src/server/api/filesystem.ts index 97f68676..62426079 100644 --- a/src/server/api/filesystem.ts +++ b/src/server/api/filesystem.ts @@ -12,6 +12,7 @@ import { execFileNoThrowWithCwd } from '../../utils/execFileNoThrow.js' import { findGitRoot, gitExe } from '../../utils/git.js' import { ripGrep } from '../../utils/ripgrep.js' import { getInitialSettings } from '../../utils/settings/settings.js' +import { isWithinRegisteredFilesystemRoot } from '../services/filesystemAccessRoots.js' type FilesystemEntry = { name: string @@ -57,6 +58,10 @@ function isAllowedFilesystemPath(targetPath: string): boolean { return true } + if (isWithinRegisteredFilesystemRoot(resolvedPath)) { + return true + } + // macOS reports /tmp as /private/tmp via native folder pickers and realpath(). if (process.platform === 'darwin' && isWithinRoot(resolvedPath, '/private/tmp')) { return true diff --git a/src/server/api/sessions.ts b/src/server/api/sessions.ts index c1bff8c9..e404d1fe 100644 --- a/src/server/api/sessions.ts +++ b/src/server/api/sessions.ts @@ -38,6 +38,7 @@ import { createSessionBranch, SessionBranchingError, } from '../../utils/sessionBranching.js' +import { registerFilesystemAccessRoot } from '../services/filesystemAccessRoots.js' const workspaceService = new WorkspaceService( async (sessionId) => ( @@ -314,7 +315,11 @@ async function getSessionRepositoryContext(url: URL): Promise { throw ApiError.badRequest('workDir query parameter is required') } - return Response.json(await getRepositoryContext(workDir)) + const context = await getRepositoryContext(workDir) + registerFilesystemAccessRoot(workDir) + registerFilesystemAccessRoot(context.workDir) + registerFilesystemAccessRoot(context.repoRoot) + return Response.json(context) } async function requireSessionWorkspace(sessionId: string): Promise { @@ -633,6 +638,7 @@ async function getGitInfo(sessionId: string): Promise { if (!workDir) { throw ApiError.notFound(`Session not found: ${sessionId}`) } + registerFilesystemAccessRoot(workDir) const launchInfo = await sessionService.getSessionLaunchInfo(sessionId).catch(() => null) const repository = launchInfo?.repository const worktreeSession = launchInfo?.worktreeSession diff --git a/src/server/services/filesystemAccessRoots.ts b/src/server/services/filesystemAccessRoots.ts new file mode 100644 index 00000000..a77ace6b --- /dev/null +++ b/src/server/services/filesystemAccessRoots.ts @@ -0,0 +1,30 @@ +import * as path from 'node:path' + +const registeredRoots = new Set() + +function normalizeComparablePath(filePath: string): string { + const resolved = path.resolve(filePath) + return process.platform === 'win32' ? resolved.toLowerCase() : resolved +} + +function isWithinRoot(targetPath: string, rootPath: string): boolean { + const target = normalizeComparablePath(targetPath) + const root = normalizeComparablePath(rootPath) + return target === root || target.startsWith(`${root}${path.sep}`) +} + +export function registerFilesystemAccessRoot(rootPath: string | null | undefined): void { + if (!rootPath) return + registeredRoots.add(path.resolve(rootPath)) +} + +export function isWithinRegisteredFilesystemRoot(targetPath: string): boolean { + for (const rootPath of registeredRoots) { + if (isWithinRoot(targetPath, rootPath)) return true + } + return false +} + +export function clearFilesystemAccessRootsForTests(): void { + registeredRoots.clear() +} diff --git a/src/server/services/repositoryLaunchService.ts b/src/server/services/repositoryLaunchService.ts index 7b7825bf..03a546e8 100644 --- a/src/server/services/repositoryLaunchService.ts +++ b/src/server/services/repositoryLaunchService.ts @@ -4,6 +4,7 @@ import * as path from 'node:path' import { promisify } from 'node:util' import { ApiError } from '../middleware/errorHandler.js' import { findCanonicalGitRoot, findGitRoot } from '../../utils/git.js' +import { registerFilesystemAccessRoot } from './filesystemAccessRoots.js' import { ensureWorktreesDirExcluded, performPostCreationSetup, @@ -326,6 +327,8 @@ export async function getRepositoryContext(workDir: string): Promise