Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 12 additions & 9 deletions src/providers/claude.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { readdir, stat } from 'fs/promises'
import { lstat, readdir } from 'fs/promises'
import { basename, join } from 'path'
import { homedir } from 'os'

Expand Down Expand Up @@ -35,20 +35,24 @@ function getDesktopSessionsDir(): string {

async function findDesktopProjectDirs(base: string): Promise<string[]> {
const results: string[] = []
// `lstat` (not `stat`) so we never descend into symbolic links. A malicious or
// misconfigured session tree could otherwise redirect us anywhere on the filesystem
// the calling user can read — outside the Claude sessions sandbox by design.
async function walk(dir: string, depth: number): Promise<void> {
if (depth > 8) return
const entries = await readdir(dir).catch(() => [])
for (const entry of entries) {
if (entry === 'node_modules' || entry === '.git') continue
const full = join(dir, entry)
const s = await stat(full).catch(() => null)
if (!s?.isDirectory()) continue
const s = await lstat(full).catch(() => null)
if (!s || s.isSymbolicLink() || !s.isDirectory()) continue
if (entry === 'projects') {
const projectDirs = await readdir(full).catch(() => [])
for (const pd of projectDirs) {
const pdFull = join(full, pd)
const pdStat = await stat(pdFull).catch(() => null)
if (pdStat?.isDirectory()) results.push(pdFull)
const pdStat = await lstat(pdFull).catch(() => null)
if (!pdStat || pdStat.isSymbolicLink() || !pdStat.isDirectory()) continue
results.push(pdFull)
}
} else {
await walk(full, depth + 1)
Expand Down Expand Up @@ -83,10 +87,9 @@ export const claude: Provider = {
const entries = await readdir(projectsDir)
for (const dirName of entries) {
const dirPath = join(projectsDir, dirName)
const dirStat = await stat(dirPath).catch(() => null)
if (dirStat?.isDirectory()) {
sources.push({ path: dirPath, project: dirName, provider: 'claude' })
}
const dirStat = await lstat(dirPath).catch(() => null)
if (!dirStat || dirStat.isSymbolicLink() || !dirStat.isDirectory()) continue
sources.push({ path: dirPath, project: dirName, provider: 'claude' })
}
} catch {}

Expand Down
62 changes: 62 additions & 0 deletions tests/providers/claude-symlink.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { mkdtemp, mkdir, writeFile, rm, symlink } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'

import { claude } from '../../src/providers/claude.js'

let tmpDir: string
let claudeDir: string
let realTarget: string
let originalConfigDir: string | undefined
let originalHome: string | undefined

beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'claude-symlink-test-'))
claudeDir = join(tmpDir, 'claude-config')
realTarget = join(tmpDir, 'outside-the-sandbox')
await mkdir(join(claudeDir, 'projects'), { recursive: true })
await mkdir(realTarget, { recursive: true })
await writeFile(join(realTarget, 'sneaky.jsonl'), '{"type":"hello"}\n')

originalConfigDir = process.env['CLAUDE_CONFIG_DIR']
originalHome = process.env['HOME']
process.env['CLAUDE_CONFIG_DIR'] = claudeDir
process.env['HOME'] = tmpDir
})

afterEach(async () => {
if (originalConfigDir === undefined) delete process.env['CLAUDE_CONFIG_DIR']
else process.env['CLAUDE_CONFIG_DIR'] = originalConfigDir
if (originalHome === undefined) delete process.env['HOME']
else process.env['HOME'] = originalHome
await rm(tmpDir, { recursive: true, force: true })
})

describe('claude provider — symlink protection in session discovery', () => {
it('skips symbolic-link entries pointing outside the projects dir', async () => {
const realProjectDir = join(claudeDir, 'projects', 'real-project')
await mkdir(realProjectDir, { recursive: true })
await symlink(realTarget, join(claudeDir, 'projects', 'shady-link'))

const sources = await claude.discoverSessions()
const claudeSources = sources.filter(s => s.provider === 'claude')
const projects = claudeSources.map(s => s.project)

expect(projects).toContain('real-project')
expect(projects).not.toContain('shady-link')
for (const s of claudeSources) {
expect(s.path.startsWith(realTarget)).toBe(false)
}
})

it('still discovers regular project directories', async () => {
await mkdir(join(claudeDir, 'projects', 'plain'), { recursive: true })

const sources = await claude.discoverSessions()
const claudeSources = sources.filter(s => s.provider === 'claude')
const projects = claudeSources.map(s => s.project)

expect(projects).toContain('plain')
})
})