From 01dcc7920cb18a9e6d76f621b3549d87574e09ff Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 23 Jul 2026 19:21:02 +0800 Subject: [PATCH] fix(tui): honor file reference boundaries --- packages/ui/tui/src/file-autocomplete.ts | 23 ++++++++++++-- packages/ui/tui/src/index.ts | 2 +- .../ui/tui/tests/file-autocomplete.spec.ts | 18 +++++++++++ packages/ui/tui/tests/tui.spec.ts | 30 +++++++++++++------ 4 files changed, 60 insertions(+), 13 deletions(-) diff --git a/packages/ui/tui/src/file-autocomplete.ts b/packages/ui/tui/src/file-autocomplete.ts index 719aa5b91b..23a3a7c1fa 100644 --- a/packages/ui/tui/src/file-autocomplete.ts +++ b/packages/ui/tui/src/file-autocomplete.ts @@ -6,7 +6,7 @@ * @module @deepseek-ai/dsh-tui/file-autocomplete */ -import { readdir } from 'node:fs/promises' +import { lstat, readdir } from 'node:fs/promises' import { isAbsolute, join, relative, resolve, sep } from 'node:path' /** Default maximum file and directory candidates rendered for one query. */ @@ -205,7 +205,7 @@ export class WorkspaceFileSearch { signal: AbortSignal, ): Promise { if (displayDirectory.split('/').some(segment => this.excludedDirectories.has(segment))) return [] - const absolute = resolveDisplayDirectory(this.root, displayDirectory) + const absolute = await resolveDisplayDirectory(this.root, displayDirectory, signal) if (absolute === undefined) return [] const entries = await readDirectory(absolute, signal) const candidates: FileSearchCandidate[] = [] @@ -222,13 +222,30 @@ export class WorkspaceFileSearch { } } -function resolveDisplayDirectory(root: string, displayDirectory: string): string | undefined { +async function resolveDisplayDirectory( + root: string, + displayDirectory: string, + signal: AbortSignal, +): Promise { const resolvedRoot = resolve(root) const absolute = resolve(resolvedRoot, displayDirectory === '' ? '.' : displayDirectory) const fromRoot = relative(resolvedRoot, absolute) if (fromRoot === '..' || fromRoot.startsWith(`..${sep}`)) return undefined /* v8 ignore next -- only Windows can produce a cross-volume absolute relative path */ if (isAbsolute(fromRoot)) return undefined + let current = resolvedRoot + for (const segment of fromRoot.split(sep).filter(Boolean)) { + signal.throwIfAborted() + current = join(current, segment) + try { + const status = await lstat(current) + signal.throwIfAborted() + if (status.isSymbolicLink() || !status.isDirectory()) return undefined + } catch (_error: unknown) { + signal.throwIfAborted() + return undefined + } + } return absolute } diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index f0781b0987..6ff66b4783 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -2485,7 +2485,7 @@ export function createTuiChat( // Tool visibility can change dynamically or by agent scope. Empty // sections are omitted by renderPrompt, so guidance never names a tool // that this agent cannot call. - text: () => agent.ctx.tools.get('read') === undefined ? '' : FILE_REFERENCE_PROMPT, + text: () => agent.ctx.tools.get('read', agent) === undefined ? '' : FILE_REFERENCE_PROMPT, }) }) diff --git a/packages/ui/tui/tests/file-autocomplete.spec.ts b/packages/ui/tui/tests/file-autocomplete.spec.ts index d918b0d489..53dd1f4f1a 100644 --- a/packages/ui/tui/tests/file-autocomplete.spec.ts +++ b/packages/ui/tui/tests/file-autocomplete.spec.ts @@ -105,6 +105,24 @@ describe('WorkspaceFileSearch', () => { ]) expect(await files.list('~/.dsh-file-autocomplete-missing/', signal)).toEqual([]) expect(await files.list('../', signal)).toEqual([]) + expect(await files.list('README.md/', signal)).toEqual([]) + }) + + it('does not traverse directory symlinks during direct completion', async () => { + const root = await workspace() + const outside = await mkdtemp(join(tmpdir(), 'dsh-file-autocomplete-outside-')) + roots.push(outside) + await writeFile(join(outside, 'outside-secret.txt'), 'secret') + await symlink( + outside, + join(root, 'escape'), + process.platform === 'win32' ? 'junction' : 'dir', + ) + const files = search(root) + const signal = new AbortController().signal + + expect(await files.list('escape/', signal)).toEqual([]) + expect(await files.list('escape/outside', signal)).toEqual([]) }) it('ranks basename and subsequence fuzzy matches across the bounded workspace index', async () => { diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 4a8e9b5e93..e935325b16 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -1153,22 +1153,34 @@ describe('pi-tui chat lifecycle and transcript', () => { }) it('shows file-reference guidance only while read is visible to the agent', async () => { - const tools: Record = {} - const result = await setup({ tools }) + const read: ToolDefinition = { + name: 'read', + description: 'Read a file.', + parameters: {}, + execute: () => Promise.resolve([]), + } + let visibility: 'none' | 'global' | 'agent' = 'none' + const result = await setup({ + async configureContext(ctx) { + ctx.provide('tools', { + get(name: string, scope?: Agent) { + if (name !== 'read' || visibility === 'none') return undefined + return (scope === undefined) === (visibility === 'global') ? read : undefined + }, + } as never) + }, + }) const fileReferenceText = async (): Promise => { const assembly = await result.ctx.systemPrompt.assemble(assembleContextFor(result.agent)) return assembly.sections.find(section => section.name === 'ui:tui-file-reference')?.text } try { expect(await fileReferenceText()).toBe('') - tools.read = { - name: 'read', - description: 'Read a file.', - parameters: {}, - execute: () => Promise.resolve([]), - } + visibility = 'global' + expect(await fileReferenceText()).toBe('') + visibility = 'agent' expect(await fileReferenceText()).toBe(FILE_REFERENCE_PROMPT) - delete tools.read + visibility = 'none' expect(await fileReferenceText()).toBe('') } finally { await dispose(result)