/ src / utils / promptShellExecution.ts
promptShellExecution.ts
  1  import { randomUUID } from 'crypto'
  2  import type { Tool, ToolUseContext } from '../Tool.js'
  3  import { BashTool } from '../tools/BashTool/BashTool.js'
  4  import { logForDebugging } from './debug.js'
  5  import { errorMessage, MalformedCommandError, ShellError } from './errors.js'
  6  import type { FrontmatterShell } from './frontmatterParser.js'
  7  import { createAssistantMessage } from './messages.js'
  8  import { hasPermissionsToUseTool } from './permissions/permissions.js'
  9  import { 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).
 19  type ShellOut = { stdout: string; stderr: string; interrupted: boolean }
 20  type PromptShellTool = Tool & {
 21    call(
 22      input: { command: string },
 23      context: ToolUseContext,
 24    ): Promise<{ data: ShellOut }>
 25  }
 26  
 27  import { 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 */
 35  const 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 ```
 49  const 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)
 56  const 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   */
 69  export 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  
145  function 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  
167  function 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  }