/ tools / GlobTool / GlobTool.ts
GlobTool.ts
  1  import { z } from 'zod/v4'
  2  import type { ValidationResult } from '../../Tool.js'
  3  import { buildTool, type ToolDef } from '../../Tool.js'
  4  import { getCwd } from '../../utils/cwd.js'
  5  import { isENOENT } from '../../utils/errors.js'
  6  import {
  7    FILE_NOT_FOUND_CWD_NOTE,
  8    suggestPathUnderCwd,
  9  } from '../../utils/file.js'
 10  import { getFsImplementation } from '../../utils/fsOperations.js'
 11  import { glob } from '../../utils/glob.js'
 12  import { lazySchema } from '../../utils/lazySchema.js'
 13  import { expandPath, toRelativePath } from '../../utils/path.js'
 14  import { checkReadPermissionForTool } from '../../utils/permissions/filesystem.js'
 15  import type { PermissionDecision } from '../../utils/permissions/PermissionResult.js'
 16  import { matchWildcardPattern } from '../../utils/permissions/shellRuleMatching.js'
 17  import { DESCRIPTION, GLOB_TOOL_NAME } from './prompt.js'
 18  import {
 19    getToolUseSummary,
 20    renderToolResultMessage,
 21    renderToolUseErrorMessage,
 22    renderToolUseMessage,
 23    userFacingName,
 24  } from './UI.js'
 25  
 26  const inputSchema = lazySchema(() =>
 27    z.strictObject({
 28      pattern: z.string().describe('The glob pattern to match files against'),
 29      path: z
 30        .string()
 31        .optional()
 32        .describe(
 33          'The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.',
 34        ),
 35    }),
 36  )
 37  type InputSchema = ReturnType<typeof inputSchema>
 38  
 39  const outputSchema = lazySchema(() =>
 40    z.object({
 41      durationMs: z
 42        .number()
 43        .describe('Time taken to execute the search in milliseconds'),
 44      numFiles: z.number().describe('Total number of files found'),
 45      filenames: z
 46        .array(z.string())
 47        .describe('Array of file paths that match the pattern'),
 48      truncated: z
 49        .boolean()
 50        .describe('Whether results were truncated (limited to 100 files)'),
 51    }),
 52  )
 53  type OutputSchema = ReturnType<typeof outputSchema>
 54  
 55  export type Output = z.infer<OutputSchema>
 56  
 57  export const GlobTool = buildTool({
 58    name: GLOB_TOOL_NAME,
 59    searchHint: 'find files by name pattern or wildcard',
 60    maxResultSizeChars: 100_000,
 61    async description() {
 62      return DESCRIPTION
 63    },
 64    userFacingName,
 65    getToolUseSummary,
 66    getActivityDescription(input) {
 67      const summary = getToolUseSummary(input)
 68      return summary ? `Finding ${summary}` : 'Finding files'
 69    },
 70    get inputSchema(): InputSchema {
 71      return inputSchema()
 72    },
 73    get outputSchema(): OutputSchema {
 74      return outputSchema()
 75    },
 76    isConcurrencySafe() {
 77      return true
 78    },
 79    isReadOnly() {
 80      return true
 81    },
 82    toAutoClassifierInput(input) {
 83      return input.pattern
 84    },
 85    isSearchOrReadCommand() {
 86      return { isSearch: true, isRead: false }
 87    },
 88    getPath({ path }): string {
 89      return path ? expandPath(path) : getCwd()
 90    },
 91    async preparePermissionMatcher({ pattern }) {
 92      return rulePattern => matchWildcardPattern(rulePattern, pattern)
 93    },
 94    async validateInput({ path }): Promise<ValidationResult> {
 95      // If path is provided, validate that it exists and is a directory
 96      if (path) {
 97        const fs = getFsImplementation()
 98        const absolutePath = expandPath(path)
 99  
100        // SECURITY: Skip filesystem operations for UNC paths to prevent NTLM credential leaks.
101        if (absolutePath.startsWith('\\\\') || absolutePath.startsWith('//')) {
102          return { result: true }
103        }
104  
105        let stats
106        try {
107          stats = await fs.stat(absolutePath)
108        } catch (e: unknown) {
109          if (isENOENT(e)) {
110            const cwdSuggestion = await suggestPathUnderCwd(absolutePath)
111            let message = `Directory does not exist: ${path}. ${FILE_NOT_FOUND_CWD_NOTE} ${getCwd()}.`
112            if (cwdSuggestion) {
113              message += ` Did you mean ${cwdSuggestion}?`
114            }
115            return {
116              result: false,
117              message,
118              errorCode: 1,
119            }
120          }
121          throw e
122        }
123  
124        if (!stats.isDirectory()) {
125          return {
126            result: false,
127            message: `Path is not a directory: ${path}`,
128            errorCode: 2,
129          }
130        }
131      }
132  
133      return { result: true }
134    },
135    async checkPermissions(input, context): Promise<PermissionDecision> {
136      const appState = context.getAppState()
137      return checkReadPermissionForTool(
138        GlobTool,
139        input,
140        appState.toolPermissionContext,
141      )
142    },
143    async prompt() {
144      return DESCRIPTION
145    },
146    renderToolUseMessage,
147    renderToolUseErrorMessage,
148    renderToolResultMessage,
149    // Reuses Grep's render (UI.tsx:65) — shows filenames.join. durationMs/
150    // numFiles are "Found 3 files in 12ms" chrome (under-count, fine).
151    extractSearchText({ filenames }) {
152      return filenames.join('\n')
153    },
154    async call(input, { abortController, getAppState, globLimits }) {
155      const start = Date.now()
156      const appState = getAppState()
157      const limit = globLimits?.maxResults ?? 100
158      const { files, truncated } = await glob(
159        input.pattern,
160        GlobTool.getPath(input),
161        { limit, offset: 0 },
162        abortController.signal,
163        appState.toolPermissionContext,
164      )
165      // Relativize paths under cwd to save tokens (same as GrepTool)
166      const filenames = files.map(toRelativePath)
167      const output: Output = {
168        filenames,
169        durationMs: Date.now() - start,
170        numFiles: filenames.length,
171        truncated,
172      }
173      return {
174        data: output,
175      }
176    },
177    mapToolResultToToolResultBlockParam(output, toolUseID) {
178      if (output.filenames.length === 0) {
179        return {
180          tool_use_id: toolUseID,
181          type: 'tool_result',
182          content: 'No files found',
183        }
184      }
185      return {
186        tool_use_id: toolUseID,
187        type: 'tool_result',
188        content: [
189          ...output.filenames,
190          ...(output.truncated
191            ? [
192                '(Results are truncated. Consider using a more specific path or pattern.)',
193              ]
194            : []),
195        ].join('\n'),
196      }
197    },
198  } satisfies ToolDef<InputSchema, Output>)