source dump of claude code
25
fork

Configure Feed

Select the types of activity you want to include in your feed.

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}