| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183 |
- import { randomUUID } from 'crypto'
- import type { Tool, ToolUseContext } from '../Tool.js'
- import { BashTool } from '../tools/BashTool/BashTool.js'
- import { logForDebugging } from './debug.js'
- import { errorMessage, MalformedCommandError, ShellError } from './errors.js'
- import type { FrontmatterShell } from './frontmatterParser.js'
- import { createAssistantMessage } from './messages.js'
- import { hasPermissionsToUseTool } from './permissions/permissions.js'
- import { processToolResultBlock } from './toolResultStorage.js'
- // Narrow structural slice both BashTool and PowerShellTool satisfy. We can't
- // use the base Tool type: it marks call()'s canUseTool/parentMessage as
- // required, but both concrete tools have them optional and the original code
- // called BashTool.call({ command }, ctx) with just 2 args. We can't use
- // `typeof BashTool` either: BashTool's input schema has fields (e.g.
- // _simulatedSedEdit) that PowerShellTool's does not.
- // NOTE: call() is invoked directly here, bypassing validateInput — any
- // load-bearing check must live in call() itself (see PR #23311).
- type ShellOut = { stdout: string; stderr: string; interrupted: boolean }
- type PromptShellTool = Tool & {
- call(
- input: { command: string },
- context: ToolUseContext,
- ): Promise<{ data: ShellOut }>
- }
- import { isPowerShellToolEnabled } from './shell/shellToolUtils.js'
- // Lazy: this file is on the startup import chain (main → commands →
- // loadSkillsDir → here). A static import would load PowerShellTool.ts
- // (and transitively parser.ts, validators, etc.) at startup on all
- // platforms, defeating tools.ts's lazy require. Deferred until the
- // first skill with `shell: powershell` actually runs.
- /* eslint-disable @typescript-eslint/no-require-imports */
- const getPowerShellTool = (() => {
- let cached: PromptShellTool | undefined
- return (): PromptShellTool => {
- if (!cached) {
- cached = (
- require('../tools/PowerShellTool/PowerShellTool.js') as typeof import('../tools/PowerShellTool/PowerShellTool.js')
- ).PowerShellTool
- }
- return cached
- }
- })()
- /* eslint-enable @typescript-eslint/no-require-imports */
- // Pattern for code blocks: ```! command ```
- const BLOCK_PATTERN = /```!\s*\n?([\s\S]*?)\n?```/g
- // Pattern for inline: !`command`
- // Uses a positive lookbehind to require whitespace or start-of-line before !
- // This prevents false matches inside markdown inline code spans like `!!` or
- // adjacent spans like `foo`!`bar`, and shell variables like $!
- // eslint-disable-next-line custom-rules/no-lookbehind-regex -- gated by text.includes('!`') below (PR#22986)
- const INLINE_PATTERN = /(?<=^|\s)!`([^`]+)`/gm
- /**
- * Parses prompt text and executes any embedded shell commands.
- * Supports two syntaxes:
- * - Code blocks: ```! command ```
- * - Inline: !`command`
- *
- * @param shell - Shell to route commands through. Defaults to bash.
- * This is *never* read from settings.defaultShell — it comes from .md
- * frontmatter (author's choice) or is undefined for built-in commands.
- * See docs/design/ps-shell-selection.md §5.3.
- */
- export async function executeShellCommandsInPrompt(
- text: string,
- context: ToolUseContext,
- slashCommandName: string,
- shell?: FrontmatterShell,
- ): Promise<string> {
- let result = text
- // Resolve the tool once. `shell === undefined` and `shell === 'bash'` both
- // hit BashTool. PowerShell only when the runtime gate allows — a skill
- // author's frontmatter choice doesn't override the user's opt-in/out.
- const shellTool: PromptShellTool =
- shell === 'powershell' && isPowerShellToolEnabled()
- ? getPowerShellTool()
- : BashTool
- // INLINE_PATTERN's lookbehind is ~100x slower than BLOCK_PATTERN on large
- // skill content (265µs vs 2µs @ 17KB). 93% of skills have no !` at all,
- // so gate the expensive scan on a cheap substring check. BLOCK_PATTERN
- // (```!) doesn't require !` in the text, so it's always scanned.
- const blockMatches = text.matchAll(BLOCK_PATTERN)
- const inlineMatches = text.includes('!`') ? text.matchAll(INLINE_PATTERN) : []
- await Promise.all(
- [...blockMatches, ...inlineMatches].map(async match => {
- const command = match[1]?.trim()
- if (command) {
- try {
- // Check permissions before executing
- const permissionResult = await hasPermissionsToUseTool(
- shellTool,
- { command },
- context,
- createAssistantMessage({ content: [] }),
- '',
- )
- if (permissionResult.behavior !== 'allow') {
- logForDebugging(
- `Shell command permission check failed for command in ${slashCommandName}: ${command}. Error: ${permissionResult.message}`,
- )
- throw new MalformedCommandError(
- `Shell command permission check failed for pattern "${match[0]}": ${permissionResult.message || 'Permission denied'}`,
- )
- }
- const { data } = await shellTool.call({ command }, context)
- // Reuse the same persistence flow as regular Bash tool calls
- const toolResultBlock = await processToolResultBlock(
- shellTool,
- data,
- randomUUID(),
- )
- // Extract the string content from the block
- const output =
- typeof toolResultBlock.content === 'string'
- ? toolResultBlock.content
- : formatBashOutput(data.stdout, data.stderr)
- // Function replacer — String.replace interprets $$, $&, $`, $' in
- // the replacement string even with a string search pattern. Shell
- // output (especially PowerShell: $env:PATH, $$, $PSVersionTable)
- // is arbitrary user data; a bare string arg would corrupt it.
- result = result.replace(match[0], () => output)
- } catch (e) {
- if (e instanceof MalformedCommandError) {
- throw e
- }
- formatBashError(e, match[0])
- }
- }
- }),
- )
- return result
- }
- function formatBashOutput(
- stdout: string,
- stderr: string,
- inline = false,
- ): string {
- const parts: string[] = []
- if (stdout.trim()) {
- parts.push(stdout.trim())
- }
- if (stderr.trim()) {
- if (inline) {
- parts.push(`[stderr: ${stderr.trim()}]`)
- } else {
- parts.push(`[stderr]\n${stderr.trim()}`)
- }
- }
- return parts.join(inline ? ' ' : '\n')
- }
- function formatBashError(e: unknown, pattern: string, inline = false): never {
- if (e instanceof ShellError) {
- if (e.interrupted) {
- throw new MalformedCommandError(
- `Shell command interrupted for pattern "${pattern}": [Command interrupted]`,
- )
- }
- const output = formatBashOutput(e.stdout, e.stderr, inline)
- throw new MalformedCommandError(
- `Shell command failed for pattern "${pattern}": ${output}`,
- )
- }
- const message = errorMessage(e)
- const formatted = inline ? `[Error: ${message}]` : `[Error]\n${message}`
- throw new MalformedCommandError(formatted)
- }
|