source dump of claude code
at main 318 lines 9.3 kB view raw
1import { z } from 'zod/v4' 2import { buildTool, type ToolDef } from '../../Tool.js' 3import type { PermissionUpdate } from '../../types/permissions.js' 4import { formatFileSize } from '../../utils/format.js' 5import { lazySchema } from '../../utils/lazySchema.js' 6import type { PermissionDecision } from '../../utils/permissions/PermissionResult.js' 7import { getRuleByContentsForTool } from '../../utils/permissions/permissions.js' 8import { isPreapprovedHost } from './preapproved.js' 9import { DESCRIPTION, WEB_FETCH_TOOL_NAME } from './prompt.js' 10import { 11 getToolUseSummary, 12 renderToolResultMessage, 13 renderToolUseMessage, 14 renderToolUseProgressMessage, 15} from './UI.js' 16import { 17 applyPromptToMarkdown, 18 type FetchedContent, 19 getURLMarkdownContent, 20 isPreapprovedUrl, 21 MAX_MARKDOWN_LENGTH, 22} from './utils.js' 23 24const inputSchema = lazySchema(() => 25 z.strictObject({ 26 url: z.string().url().describe('The URL to fetch content from'), 27 prompt: z.string().describe('The prompt to run on the fetched content'), 28 }), 29) 30type InputSchema = ReturnType<typeof inputSchema> 31 32const outputSchema = lazySchema(() => 33 z.object({ 34 bytes: z.number().describe('Size of the fetched content in bytes'), 35 code: z.number().describe('HTTP response code'), 36 codeText: z.string().describe('HTTP response code text'), 37 result: z 38 .string() 39 .describe('Processed result from applying the prompt to the content'), 40 durationMs: z 41 .number() 42 .describe('Time taken to fetch and process the content'), 43 url: z.string().describe('The URL that was fetched'), 44 }), 45) 46type OutputSchema = ReturnType<typeof outputSchema> 47 48export type Output = z.infer<OutputSchema> 49 50function webFetchToolInputToPermissionRuleContent(input: { 51 [k: string]: unknown 52}): string { 53 try { 54 const parsedInput = WebFetchTool.inputSchema.safeParse(input) 55 if (!parsedInput.success) { 56 return `input:${input.toString()}` 57 } 58 const { url } = parsedInput.data 59 const hostname = new URL(url).hostname 60 return `domain:${hostname}` 61 } catch { 62 return `input:${input.toString()}` 63 } 64} 65 66export const WebFetchTool = buildTool({ 67 name: WEB_FETCH_TOOL_NAME, 68 searchHint: 'fetch and extract content from a URL', 69 // 100K chars - tool result persistence threshold 70 maxResultSizeChars: 100_000, 71 shouldDefer: true, 72 async description(input) { 73 const { url } = input as { url: string } 74 try { 75 const hostname = new URL(url).hostname 76 return `Claude wants to fetch content from ${hostname}` 77 } catch { 78 return `Claude wants to fetch content from this URL` 79 } 80 }, 81 userFacingName() { 82 return 'Fetch' 83 }, 84 getToolUseSummary, 85 getActivityDescription(input) { 86 const summary = getToolUseSummary(input) 87 return summary ? `Fetching ${summary}` : 'Fetching web page' 88 }, 89 get inputSchema(): InputSchema { 90 return inputSchema() 91 }, 92 get outputSchema(): OutputSchema { 93 return outputSchema() 94 }, 95 isConcurrencySafe() { 96 return true 97 }, 98 isReadOnly() { 99 return true 100 }, 101 toAutoClassifierInput(input) { 102 return input.prompt ? `${input.url}: ${input.prompt}` : input.url 103 }, 104 async checkPermissions(input, context): Promise<PermissionDecision> { 105 const appState = context.getAppState() 106 const permissionContext = appState.toolPermissionContext 107 108 // Check if the hostname is in the preapproved list 109 try { 110 const { url } = input as { url: string } 111 const parsedUrl = new URL(url) 112 if (isPreapprovedHost(parsedUrl.hostname, parsedUrl.pathname)) { 113 return { 114 behavior: 'allow', 115 updatedInput: input, 116 decisionReason: { type: 'other', reason: 'Preapproved host' }, 117 } 118 } 119 } catch { 120 // If URL parsing fails, continue with normal permission checks 121 } 122 123 // Check for a rule specific to the tool input (matching hostname) 124 const ruleContent = webFetchToolInputToPermissionRuleContent(input) 125 126 const denyRule = getRuleByContentsForTool( 127 permissionContext, 128 WebFetchTool, 129 'deny', 130 ).get(ruleContent) 131 if (denyRule) { 132 return { 133 behavior: 'deny', 134 message: `${WebFetchTool.name} denied access to ${ruleContent}.`, 135 decisionReason: { 136 type: 'rule', 137 rule: denyRule, 138 }, 139 } 140 } 141 142 const askRule = getRuleByContentsForTool( 143 permissionContext, 144 WebFetchTool, 145 'ask', 146 ).get(ruleContent) 147 if (askRule) { 148 return { 149 behavior: 'ask', 150 message: `Claude requested permissions to use ${WebFetchTool.name}, but you haven't granted it yet.`, 151 decisionReason: { 152 type: 'rule', 153 rule: askRule, 154 }, 155 suggestions: buildSuggestions(ruleContent), 156 } 157 } 158 159 const allowRule = getRuleByContentsForTool( 160 permissionContext, 161 WebFetchTool, 162 'allow', 163 ).get(ruleContent) 164 if (allowRule) { 165 return { 166 behavior: 'allow', 167 updatedInput: input, 168 decisionReason: { 169 type: 'rule', 170 rule: allowRule, 171 }, 172 } 173 } 174 175 return { 176 behavior: 'ask', 177 message: `Claude requested permissions to use ${WebFetchTool.name}, but you haven't granted it yet.`, 178 suggestions: buildSuggestions(ruleContent), 179 } 180 }, 181 async prompt(_options) { 182 // Always include the auth warning regardless of whether ToolSearch is 183 // currently in the tools list. Conditionally toggling this prefix based 184 // on ToolSearch availability caused the tool description to flicker 185 // between SDK query() calls (when ToolSearch enablement varies due to 186 // MCP tool count thresholds), invalidating the Anthropic API prompt 187 // cache on each toggle — two consecutive cache misses per flicker event. 188 return `IMPORTANT: WebFetch WILL FAIL for authenticated or private URLs. Before using this tool, check if the URL points to an authenticated service (e.g. Google Docs, Confluence, Jira, GitHub). If so, look for a specialized MCP tool that provides authenticated access. 189${DESCRIPTION}` 190 }, 191 async validateInput(input) { 192 const { url } = input 193 try { 194 new URL(url) 195 } catch { 196 return { 197 result: false, 198 message: `Error: Invalid URL "${url}". The URL provided could not be parsed.`, 199 meta: { reason: 'invalid_url' }, 200 errorCode: 1, 201 } 202 } 203 return { result: true } 204 }, 205 renderToolUseMessage, 206 renderToolUseProgressMessage, 207 renderToolResultMessage, 208 async call( 209 { url, prompt }, 210 { abortController, options: { isNonInteractiveSession } }, 211 ) { 212 const start = Date.now() 213 214 const response = await getURLMarkdownContent(url, abortController) 215 216 // Check if we got a redirect to a different host 217 if ('type' in response && response.type === 'redirect') { 218 const statusText = 219 response.statusCode === 301 220 ? 'Moved Permanently' 221 : response.statusCode === 308 222 ? 'Permanent Redirect' 223 : response.statusCode === 307 224 ? 'Temporary Redirect' 225 : 'Found' 226 227 const message = `REDIRECT DETECTED: The URL redirects to a different host. 228 229Original URL: ${response.originalUrl} 230Redirect URL: ${response.redirectUrl} 231Status: ${response.statusCode} ${statusText} 232 233To complete your request, I need to fetch content from the redirected URL. Please use WebFetch again with these parameters: 234- url: "${response.redirectUrl}" 235- prompt: "${prompt}"` 236 237 const output: Output = { 238 bytes: Buffer.byteLength(message), 239 code: response.statusCode, 240 codeText: statusText, 241 result: message, 242 durationMs: Date.now() - start, 243 url, 244 } 245 246 return { 247 data: output, 248 } 249 } 250 251 const { 252 content, 253 bytes, 254 code, 255 codeText, 256 contentType, 257 persistedPath, 258 persistedSize, 259 } = response as FetchedContent 260 261 const isPreapproved = isPreapprovedUrl(url) 262 263 let result: string 264 if ( 265 isPreapproved && 266 contentType.includes('text/markdown') && 267 content.length < MAX_MARKDOWN_LENGTH 268 ) { 269 result = content 270 } else { 271 result = await applyPromptToMarkdown( 272 prompt, 273 content, 274 abortController.signal, 275 isNonInteractiveSession, 276 isPreapproved, 277 ) 278 } 279 280 // Binary content (PDFs, etc.) was additionally saved to disk with a 281 // mime-derived extension. Note it so Claude can inspect the raw file 282 // if the Haiku summary above isn't enough. 283 if (persistedPath) { 284 result += `\n\n[Binary content (${contentType}, ${formatFileSize(persistedSize ?? bytes)}) also saved to ${persistedPath}]` 285 } 286 287 const output: Output = { 288 bytes, 289 code, 290 codeText, 291 result, 292 durationMs: Date.now() - start, 293 url, 294 } 295 296 return { 297 data: output, 298 } 299 }, 300 mapToolResultToToolResultBlockParam({ result }, toolUseID) { 301 return { 302 tool_use_id: toolUseID, 303 type: 'tool_result', 304 content: result, 305 } 306 }, 307} satisfies ToolDef<InputSchema, Output>) 308 309function buildSuggestions(ruleContent: string): PermissionUpdate[] { 310 return [ 311 { 312 type: 'addRules', 313 destination: 'localSettings', 314 rules: [{ toolName: WEB_FETCH_TOOL_NAME, ruleContent }], 315 behavior: 'allow', 316 }, 317 ] 318}