source dump of claude code
at main 183 lines 7.0 kB view raw
1import { randomUUID } from 'crypto' 2import type { Tool, ToolUseContext } from '../Tool.js' 3import { BashTool } from '../tools/BashTool/BashTool.js' 4import { logForDebugging } from './debug.js' 5import { errorMessage, MalformedCommandError, ShellError } from './errors.js' 6import type { FrontmatterShell } from './frontmatterParser.js' 7import { createAssistantMessage } from './messages.js' 8import { hasPermissionsToUseTool } from './permissions/permissions.js' 9import { processToolResultBlock } from './toolResultStorage.js' 10 11// Narrow structural slice both BashTool and PowerShellTool satisfy. We can't 12// use the base Tool type: it marks call()'s canUseTool/parentMessage as 13// required, but both concrete tools have them optional and the original code 14// called BashTool.call({ command }, ctx) with just 2 args. We can't use 15// `typeof BashTool` either: BashTool's input schema has fields (e.g. 16// _simulatedSedEdit) that PowerShellTool's does not. 17// NOTE: call() is invoked directly here, bypassing validateInput — any 18// load-bearing check must live in call() itself (see PR #23311). 19type ShellOut = { stdout: string; stderr: string; interrupted: boolean } 20type PromptShellTool = Tool & { 21 call( 22 input: { command: string }, 23 context: ToolUseContext, 24 ): Promise<{ data: ShellOut }> 25} 26 27import { isPowerShellToolEnabled } from './shell/shellToolUtils.js' 28 29// Lazy: this file is on the startup import chain (main → commands → 30// loadSkillsDir → here). A static import would load PowerShellTool.ts 31// (and transitively parser.ts, validators, etc.) at startup on all 32// platforms, defeating tools.ts's lazy require. Deferred until the 33// first skill with `shell: powershell` actually runs. 34/* eslint-disable @typescript-eslint/no-require-imports */ 35const getPowerShellTool = (() => { 36 let cached: PromptShellTool | undefined 37 return (): PromptShellTool => { 38 if (!cached) { 39 cached = ( 40 require('../tools/PowerShellTool/PowerShellTool.js') as typeof import('../tools/PowerShellTool/PowerShellTool.js') 41 ).PowerShellTool 42 } 43 return cached 44 } 45})() 46/* eslint-enable @typescript-eslint/no-require-imports */ 47 48// Pattern for code blocks: ```! command ``` 49const BLOCK_PATTERN = /```!\s*\n?([\s\S]*?)\n?```/g 50 51// Pattern for inline: !`command` 52// Uses a positive lookbehind to require whitespace or start-of-line before ! 53// This prevents false matches inside markdown inline code spans like `!!` or 54// adjacent spans like `foo`!`bar`, and shell variables like $! 55// eslint-disable-next-line custom-rules/no-lookbehind-regex -- gated by text.includes('!`') below (PR#22986) 56const INLINE_PATTERN = /(?<=^|\s)!`([^`]+)`/gm 57 58/** 59 * Parses prompt text and executes any embedded shell commands. 60 * Supports two syntaxes: 61 * - Code blocks: ```! command ``` 62 * - Inline: !`command` 63 * 64 * @param shell - Shell to route commands through. Defaults to bash. 65 * This is *never* read from settings.defaultShell — it comes from .md 66 * frontmatter (author's choice) or is undefined for built-in commands. 67 * See docs/design/ps-shell-selection.md §5.3. 68 */ 69export async function executeShellCommandsInPrompt( 70 text: string, 71 context: ToolUseContext, 72 slashCommandName: string, 73 shell?: FrontmatterShell, 74): Promise<string> { 75 let result = text 76 77 // Resolve the tool once. `shell === undefined` and `shell === 'bash'` both 78 // hit BashTool. PowerShell only when the runtime gate allows — a skill 79 // author's frontmatter choice doesn't override the user's opt-in/out. 80 const shellTool: PromptShellTool = 81 shell === 'powershell' && isPowerShellToolEnabled() 82 ? getPowerShellTool() 83 : BashTool 84 85 // INLINE_PATTERN's lookbehind is ~100x slower than BLOCK_PATTERN on large 86 // skill content (265µs vs 2µs @ 17KB). 93% of skills have no !` at all, 87 // so gate the expensive scan on a cheap substring check. BLOCK_PATTERN 88 // (```!) doesn't require !` in the text, so it's always scanned. 89 const blockMatches = text.matchAll(BLOCK_PATTERN) 90 const inlineMatches = text.includes('!`') ? text.matchAll(INLINE_PATTERN) : [] 91 92 await Promise.all( 93 [...blockMatches, ...inlineMatches].map(async match => { 94 const command = match[1]?.trim() 95 if (command) { 96 try { 97 // Check permissions before executing 98 const permissionResult = await hasPermissionsToUseTool( 99 shellTool, 100 { command }, 101 context, 102 createAssistantMessage({ content: [] }), 103 '', 104 ) 105 106 if (permissionResult.behavior !== 'allow') { 107 logForDebugging( 108 `Shell command permission check failed for command in ${slashCommandName}: ${command}. Error: ${permissionResult.message}`, 109 ) 110 throw new MalformedCommandError( 111 `Shell command permission check failed for pattern "${match[0]}": ${permissionResult.message || 'Permission denied'}`, 112 ) 113 } 114 115 const { data } = await shellTool.call({ command }, context) 116 // Reuse the same persistence flow as regular Bash tool calls 117 const toolResultBlock = await processToolResultBlock( 118 shellTool, 119 data, 120 randomUUID(), 121 ) 122 // Extract the string content from the block 123 const output = 124 typeof toolResultBlock.content === 'string' 125 ? toolResultBlock.content 126 : formatBashOutput(data.stdout, data.stderr) 127 // Function replacer — String.replace interprets $$, $&, $`, $' in 128 // the replacement string even with a string search pattern. Shell 129 // output (especially PowerShell: $env:PATH, $$, $PSVersionTable) 130 // is arbitrary user data; a bare string arg would corrupt it. 131 result = result.replace(match[0], () => output) 132 } catch (e) { 133 if (e instanceof MalformedCommandError) { 134 throw e 135 } 136 formatBashError(e, match[0]) 137 } 138 } 139 }), 140 ) 141 142 return result 143} 144 145function formatBashOutput( 146 stdout: string, 147 stderr: string, 148 inline = false, 149): string { 150 const parts: string[] = [] 151 152 if (stdout.trim()) { 153 parts.push(stdout.trim()) 154 } 155 156 if (stderr.trim()) { 157 if (inline) { 158 parts.push(`[stderr: ${stderr.trim()}]`) 159 } else { 160 parts.push(`[stderr]\n${stderr.trim()}`) 161 } 162 } 163 164 return parts.join(inline ? ' ' : '\n') 165} 166 167function formatBashError(e: unknown, pattern: string, inline = false): never { 168 if (e instanceof ShellError) { 169 if (e.interrupted) { 170 throw new MalformedCommandError( 171 `Shell command interrupted for pattern "${pattern}": [Command interrupted]`, 172 ) 173 } 174 const output = formatBashOutput(e.stdout, e.stderr, inline) 175 throw new MalformedCommandError( 176 `Shell command failed for pattern "${pattern}": ${output}`, 177 ) 178 } 179 180 const message = errorMessage(e) 181 const formatted = inline ? `[Error: ${message}]` : `[Error]\n${message}` 182 throw new MalformedCommandError(formatted) 183}