Просмотр исходного кода

fix(tui): honor file reference boundaries

Yichen Jiang 2 месяцев назад
Родитель
Сommit
01dcc7920c

+ 20 - 3
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<FileSearchCandidate[]> {
     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<string | undefined> {
   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
 }
 

+ 1 - 1
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,
     })
   })
 

+ 18 - 0
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 () => {

+ 21 - 9
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<string, ToolDefinition> = {}
-    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<string | undefined> => {
       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)