source dump of claude code
at main 265 lines 8.6 kB view raw
1import type { z } from 'zod/v4' 2import { 3 isUnsafeCompoundCommand_DEPRECATED, 4 splitCommand_DEPRECATED, 5} from '../../utils/bash/commands.js' 6import { 7 buildParsedCommandFromRoot, 8 type IParsedCommand, 9 ParsedCommand, 10} from '../../utils/bash/ParsedCommand.js' 11import { type Node, PARSE_ABORTED } from '../../utils/bash/parser.js' 12import type { PermissionResult } from '../../utils/permissions/PermissionResult.js' 13import type { PermissionUpdate } from '../../utils/permissions/PermissionUpdateSchema.js' 14import { createPermissionRequestMessage } from '../../utils/permissions/permissions.js' 15import { BashTool } from './BashTool.js' 16import { bashCommandIsSafeAsync_DEPRECATED } from './bashSecurity.js' 17 18export type CommandIdentityCheckers = { 19 isNormalizedCdCommand: (command: string) => boolean 20 isNormalizedGitCommand: (command: string) => boolean 21} 22 23async 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 */ 163async 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 */ 181export 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 */ 208async 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}