import type { z } from 'zod/v4' import { isUnsafeCompoundCommand_DEPRECATED, splitCommand_DEPRECATED, } from '../../utils/bash/commands.js' import { buildParsedCommandFromRoot, type IParsedCommand, ParsedCommand, } from '../../utils/bash/ParsedCommand.js' import { type Node, PARSE_ABORTED } from '../../utils/bash/parser.js' import type { PermissionResult } from '../../utils/permissions/PermissionResult.js' import type { PermissionUpdate } from '../../utils/permissions/PermissionUpdateSchema.js' import { createPermissionRequestMessage } from '../../utils/permissions/permissions.js' import { BashTool } from './BashTool.js' import { bashCommandIsSafeAsync_DEPRECATED } from './bashSecurity.js' export type CommandIdentityCheckers = { isNormalizedCdCommand: (command: string) => boolean isNormalizedGitCommand: (command: string) => boolean } async function segmentedCommandPermissionResult( input: z.infer, segments: string[], bashToolHasPermissionFn: ( input: z.infer, ) => Promise, checkers: CommandIdentityCheckers, ): Promise { // Check for multiple cd commands across all segments const cdCommands = segments.filter(segment => { const trimmed = segment.trim() return checkers.isNormalizedCdCommand(trimmed) }) if (cdCommands.length > 1) { const decisionReason = { type: 'other' as const, reason: 'Multiple directory changes in one command require approval for clarity', } return { behavior: 'ask', decisionReason, message: createPermissionRequestMessage(BashTool.name, decisionReason), } } // SECURITY: Check for cd+git across pipe segments to prevent bare repo fsmonitor bypass. // When cd and git are in different pipe segments (e.g., "cd sub && echo | git status"), // each segment is checked independently and neither triggers the cd+git check in // bashPermissions.ts. We must detect this cross-segment pattern here. // Each pipe segment can itself be a compound command (e.g., "cd sub && echo"), // so we split each segment into subcommands before checking. { let hasCd = false let hasGit = false for (const segment of segments) { const subcommands = splitCommand_DEPRECATED(segment) for (const sub of subcommands) { const trimmed = sub.trim() if (checkers.isNormalizedCdCommand(trimmed)) { hasCd = true } if (checkers.isNormalizedGitCommand(trimmed)) { hasGit = true } } } if (hasCd && hasGit) { const decisionReason = { type: 'other' as const, reason: 'Compound commands with cd and git require approval to prevent bare repository attacks', } return { behavior: 'ask', decisionReason, message: createPermissionRequestMessage(BashTool.name, decisionReason), } } } const segmentResults = new Map() // Check each segment through the full permission system for (const segment of segments) { const trimmedSegment = segment.trim() if (!trimmedSegment) continue // Skip empty segments const segmentResult = await bashToolHasPermissionFn({ ...input, command: trimmedSegment, }) segmentResults.set(trimmedSegment, segmentResult) } // Check if any segment is denied (after evaluating all) const deniedSegment = Array.from(segmentResults.entries()).find( ([, result]) => result.behavior === 'deny', ) if (deniedSegment) { const [segmentCommand, segmentResult] = deniedSegment return { behavior: 'deny', message: segmentResult.behavior === 'deny' ? segmentResult.message : `Permission denied for: ${segmentCommand}`, decisionReason: { type: 'subcommandResults', reasons: segmentResults, }, } } const allAllowed = Array.from(segmentResults.values()).every( result => result.behavior === 'allow', ) if (allAllowed) { return { behavior: 'allow', updatedInput: input, decisionReason: { type: 'subcommandResults', reasons: segmentResults, }, } } // Collect suggestions from segments that need approval const suggestions: PermissionUpdate[] = [] for (const [, result] of segmentResults) { if ( result.behavior !== 'allow' && 'suggestions' in result && result.suggestions ) { suggestions.push(...result.suggestions) } } const decisionReason = { type: 'subcommandResults' as const, reasons: segmentResults, } return { behavior: 'ask', message: createPermissionRequestMessage(BashTool.name, decisionReason), decisionReason, suggestions: suggestions.length > 0 ? suggestions : undefined, } } /** * Builds a command segment, stripping output redirections to avoid * treating filenames as commands in permission checking. * Uses ParsedCommand to preserve original quoting. */ async function buildSegmentWithoutRedirections( segmentCommand: string, ): Promise { // Fast path: skip parsing if no redirection operators present if (!segmentCommand.includes('>')) { return segmentCommand } // Use ParsedCommand to strip redirections while preserving quotes const parsed = await ParsedCommand.parse(segmentCommand) return parsed?.withoutOutputRedirections() ?? segmentCommand } /** * Wrapper that resolves an IParsedCommand (from a pre-parsed AST root if * available, else via ParsedCommand.parse) and delegates to * bashToolCheckCommandOperatorPermissions. */ export async function checkCommandOperatorPermissions( input: z.infer, bashToolHasPermissionFn: ( input: z.infer, ) => Promise, checkers: CommandIdentityCheckers, astRoot: Node | null | typeof PARSE_ABORTED, ): Promise { const parsed = astRoot && astRoot !== PARSE_ABORTED ? buildParsedCommandFromRoot(input.command, astRoot) : await ParsedCommand.parse(input.command) if (!parsed) { return { behavior: 'passthrough', message: 'Failed to parse command' } } return bashToolCheckCommandOperatorPermissions( input, bashToolHasPermissionFn, checkers, parsed, ) } /** * Checks if the command has special operators that require behavior beyond * simple subcommand checking. */ async function bashToolCheckCommandOperatorPermissions( input: z.infer, bashToolHasPermissionFn: ( input: z.infer, ) => Promise, checkers: CommandIdentityCheckers, parsed: IParsedCommand, ): Promise { // 1. Check for unsafe compound commands (subshells, command groups). const tsAnalysis = parsed.getTreeSitterAnalysis() const isUnsafeCompound = tsAnalysis ? tsAnalysis.compoundStructure.hasSubshell || tsAnalysis.compoundStructure.hasCommandGroup : isUnsafeCompoundCommand_DEPRECATED(input.command) if (isUnsafeCompound) { // This command contains an operator like `>` that we don't support as a subcommand separator // Check if bashCommandIsSafe_DEPRECATED has a more specific message const safetyResult = await bashCommandIsSafeAsync_DEPRECATED(input.command) const decisionReason = { type: 'other' as const, reason: safetyResult.behavior === 'ask' && safetyResult.message ? safetyResult.message : 'This command uses shell operators that require approval for safety', } return { behavior: 'ask', message: createPermissionRequestMessage(BashTool.name, decisionReason), decisionReason, // This is an unsafe compound command, so we don't want to suggest rules since we wont be able to allow it } } // 2. Check for piped commands using ParsedCommand (preserves quotes) const pipeSegments = parsed.getPipeSegments() // If no pipes (single segment), let normal flow handle it if (pipeSegments.length <= 1) { return { behavior: 'passthrough', message: 'No pipes found in command', } } // Strip output redirections from each segment while preserving quotes const segments = await Promise.all( pipeSegments.map(segment => buildSegmentWithoutRedirections(segment)), ) // Handle as segmented command return segmentedCommandPermissionResult( input, segments, bashToolHasPermissionFn, checkers, ) }