/ tools / BashTool / bashCommandHelpers.ts
bashCommandHelpers.ts
  1  import type { z } from 'zod/v4'
  2  import {
  3    isUnsafeCompoundCommand_DEPRECATED,
  4    splitCommand_DEPRECATED,
  5  } from '../../utils/bash/commands.js'
  6  import {
  7    buildParsedCommandFromRoot,
  8    type IParsedCommand,
  9    ParsedCommand,
 10  } from '../../utils/bash/ParsedCommand.js'
 11  import { type Node, PARSE_ABORTED } from '../../utils/bash/parser.js'
 12  import type { PermissionResult } from '../../utils/permissions/PermissionResult.js'
 13  import type { PermissionUpdate } from '../../utils/permissions/PermissionUpdateSchema.js'
 14  import { createPermissionRequestMessage } from '../../utils/permissions/permissions.js'
 15  import { BashTool } from './BashTool.js'
 16  import { bashCommandIsSafeAsync_DEPRECATED } from './bashSecurity.js'
 17  
 18  export type CommandIdentityCheckers = {
 19    isNormalizedCdCommand: (command: string) => boolean
 20    isNormalizedGitCommand: (command: string) => boolean
 21  }
 22  
 23  async function segmentedCommandPermissionResult(
 24    input: z.infer<typeof BashTool.inputSchema>,
 25    segments: string[],
 26    bashToolHasPermissionFn: (
 27      input: z.infer<typeof BashTool.inputSchema>,
 28    ) => Promise<PermissionResult>,
 29    checkers: CommandIdentityCheckers,
 30  ): Promise<PermissionResult> {
 31    // Check for multiple cd commands across all segments
 32    const cdCommands = segments.filter(segment => {
 33      const trimmed = segment.trim()
 34      return checkers.isNormalizedCdCommand(trimmed)
 35    })
 36    if (cdCommands.length > 1) {
 37      const decisionReason = {
 38        type: 'other' as const,
 39        reason:
 40          'Multiple directory changes in one command require approval for clarity',
 41      }
 42      return {
 43        behavior: 'ask',
 44        decisionReason,
 45        message: createPermissionRequestMessage(BashTool.name, decisionReason),
 46      }
 47    }
 48  
 49    // SECURITY: Check for cd+git across pipe segments to prevent bare repo fsmonitor bypass.
 50    // When cd and git are in different pipe segments (e.g., "cd sub && echo | git status"),
 51    // each segment is checked independently and neither triggers the cd+git check in
 52    // bashPermissions.ts. We must detect this cross-segment pattern here.
 53    // Each pipe segment can itself be a compound command (e.g., "cd sub && echo"),
 54    // so we split each segment into subcommands before checking.
 55    {
 56      let hasCd = false
 57      let hasGit = false
 58      for (const segment of segments) {
 59        const subcommands = splitCommand_DEPRECATED(segment)
 60        for (const sub of subcommands) {
 61          const trimmed = sub.trim()
 62          if (checkers.isNormalizedCdCommand(trimmed)) {
 63            hasCd = true
 64          }
 65          if (checkers.isNormalizedGitCommand(trimmed)) {
 66            hasGit = true
 67          }
 68        }
 69      }
 70      if (hasCd && hasGit) {
 71        const decisionReason = {
 72          type: 'other' as const,
 73          reason:
 74            'Compound commands with cd and git require approval to prevent bare repository attacks',
 75        }
 76        return {
 77          behavior: 'ask',
 78          decisionReason,
 79          message: createPermissionRequestMessage(BashTool.name, decisionReason),
 80        }
 81      }
 82    }
 83  
 84    const segmentResults = new Map<string, PermissionResult>()
 85  
 86    // Check each segment through the full permission system
 87    for (const segment of segments) {
 88      const trimmedSegment = segment.trim()
 89      if (!trimmedSegment) continue // Skip empty segments
 90  
 91      const segmentResult = await bashToolHasPermissionFn({
 92        ...input,
 93        command: trimmedSegment,
 94      })
 95      segmentResults.set(trimmedSegment, segmentResult)
 96    }
 97  
 98    // Check if any segment is denied (after evaluating all)
 99    const deniedSegment = Array.from(segmentResults.entries()).find(
100      ([, result]) => result.behavior === 'deny',
101    )
102  
103    if (deniedSegment) {
104      const [segmentCommand, segmentResult] = deniedSegment
105      return {
106        behavior: 'deny',
107        message:
108          segmentResult.behavior === 'deny'
109            ? segmentResult.message
110            : `Permission denied for: ${segmentCommand}`,
111        decisionReason: {
112          type: 'subcommandResults',
113          reasons: segmentResults,
114        },
115      }
116    }
117  
118    const allAllowed = Array.from(segmentResults.values()).every(
119      result => result.behavior === 'allow',
120    )
121  
122    if (allAllowed) {
123      return {
124        behavior: 'allow',
125        updatedInput: input,
126        decisionReason: {
127          type: 'subcommandResults',
128          reasons: segmentResults,
129        },
130      }
131    }
132  
133    // Collect suggestions from segments that need approval
134    const suggestions: PermissionUpdate[] = []
135    for (const [, result] of segmentResults) {
136      if (
137        result.behavior !== 'allow' &&
138        'suggestions' in result &&
139        result.suggestions
140      ) {
141        suggestions.push(...result.suggestions)
142      }
143    }
144  
145    const decisionReason = {
146      type: 'subcommandResults' as const,
147      reasons: segmentResults,
148    }
149  
150    return {
151      behavior: 'ask',
152      message: createPermissionRequestMessage(BashTool.name, decisionReason),
153      decisionReason,
154      suggestions: suggestions.length > 0 ? suggestions : undefined,
155    }
156  }
157  
158  /**
159   * Builds a command segment, stripping output redirections to avoid
160   * treating filenames as commands in permission checking.
161   * Uses ParsedCommand to preserve original quoting.
162   */
163  async function buildSegmentWithoutRedirections(
164    segmentCommand: string,
165  ): Promise<string> {
166    // Fast path: skip parsing if no redirection operators present
167    if (!segmentCommand.includes('>')) {
168      return segmentCommand
169    }
170  
171    // Use ParsedCommand to strip redirections while preserving quotes
172    const parsed = await ParsedCommand.parse(segmentCommand)
173    return parsed?.withoutOutputRedirections() ?? segmentCommand
174  }
175  
176  /**
177   * Wrapper that resolves an IParsedCommand (from a pre-parsed AST root if
178   * available, else via ParsedCommand.parse) and delegates to
179   * bashToolCheckCommandOperatorPermissions.
180   */
181  export async function checkCommandOperatorPermissions(
182    input: z.infer<typeof BashTool.inputSchema>,
183    bashToolHasPermissionFn: (
184      input: z.infer<typeof BashTool.inputSchema>,
185    ) => Promise<PermissionResult>,
186    checkers: CommandIdentityCheckers,
187    astRoot: Node | null | typeof PARSE_ABORTED,
188  ): Promise<PermissionResult> {
189    const parsed =
190      astRoot && astRoot !== PARSE_ABORTED
191        ? buildParsedCommandFromRoot(input.command, astRoot)
192        : await ParsedCommand.parse(input.command)
193    if (!parsed) {
194      return { behavior: 'passthrough', message: 'Failed to parse command' }
195    }
196    return bashToolCheckCommandOperatorPermissions(
197      input,
198      bashToolHasPermissionFn,
199      checkers,
200      parsed,
201    )
202  }
203  
204  /**
205   * Checks if the command has special operators that require behavior beyond
206   * simple subcommand checking.
207   */
208  async function bashToolCheckCommandOperatorPermissions(
209    input: z.infer<typeof BashTool.inputSchema>,
210    bashToolHasPermissionFn: (
211      input: z.infer<typeof BashTool.inputSchema>,
212    ) => Promise<PermissionResult>,
213    checkers: CommandIdentityCheckers,
214    parsed: IParsedCommand,
215  ): Promise<PermissionResult> {
216    // 1. Check for unsafe compound commands (subshells, command groups).
217    const tsAnalysis = parsed.getTreeSitterAnalysis()
218    const isUnsafeCompound = tsAnalysis
219      ? tsAnalysis.compoundStructure.hasSubshell ||
220        tsAnalysis.compoundStructure.hasCommandGroup
221      : isUnsafeCompoundCommand_DEPRECATED(input.command)
222    if (isUnsafeCompound) {
223      // This command contains an operator like `>` that we don't support as a subcommand separator
224      // Check if bashCommandIsSafe_DEPRECATED has a more specific message
225      const safetyResult = await bashCommandIsSafeAsync_DEPRECATED(input.command)
226  
227      const decisionReason = {
228        type: 'other' as const,
229        reason:
230          safetyResult.behavior === 'ask' && safetyResult.message
231            ? safetyResult.message
232            : 'This command uses shell operators that require approval for safety',
233      }
234      return {
235        behavior: 'ask',
236        message: createPermissionRequestMessage(BashTool.name, decisionReason),
237        decisionReason,
238        // This is an unsafe compound command, so we don't want to suggest rules since we wont be able to allow it
239      }
240    }
241  
242    // 2. Check for piped commands using ParsedCommand (preserves quotes)
243    const pipeSegments = parsed.getPipeSegments()
244  
245    // If no pipes (single segment), let normal flow handle it
246    if (pipeSegments.length <= 1) {
247      return {
248        behavior: 'passthrough',
249        message: 'No pipes found in command',
250      }
251    }
252  
253    // Strip output redirections from each segment while preserving quotes
254    const segments = await Promise.all(
255      pipeSegments.map(segment => buildSegmentWithoutRedirections(segment)),
256    )
257  
258    // Handle as segmented command
259    return segmentedCommandPermissionResult(
260      input,
261      segments,
262      bashToolHasPermissionFn,
263      checkers,
264    )
265  }