promptShellExecution.ts 6.8 KB

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