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>)