source dump of claude code
23
fork

Configure Feed

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

at main 684 lines 22 kB view raw
1import type { ToolPermissionContext } from '../../Tool.js' 2import { splitCommand_DEPRECATED } from '../../utils/bash/commands.js' 3import { tryParseShellCommand } from '../../utils/bash/shellQuote.js' 4import type { PermissionResult } from '../../utils/permissions/PermissionResult.js' 5 6/** 7 * Helper: Validate flags against an allowlist 8 * Handles both single flags and combined flags (e.g., -nE) 9 * @param flags Array of flags to validate 10 * @param allowedFlags Array of allowed single-character and long flags 11 * @returns true if all flags are valid, false otherwise 12 */ 13function validateFlagsAgainstAllowlist( 14 flags: string[], 15 allowedFlags: string[], 16): boolean { 17 for (const flag of flags) { 18 // Handle combined flags like -nE or -Er 19 if (flag.startsWith('-') && !flag.startsWith('--') && flag.length > 2) { 20 // Check each character in combined flag 21 for (let i = 1; i < flag.length; i++) { 22 const singleFlag = '-' + flag[i] 23 if (!allowedFlags.includes(singleFlag)) { 24 return false 25 } 26 } 27 } else { 28 // Single flag or long flag 29 if (!allowedFlags.includes(flag)) { 30 return false 31 } 32 } 33 } 34 return true 35} 36 37/** 38 * Pattern 1: Check if this is a line printing command with -n flag 39 * Allows: sed -n 'N' | sed -n 'N,M' with optional -E, -r, -z flags 40 * Allows semicolon-separated print commands like: sed -n '1p;2p;3p' 41 * File arguments are ALLOWED for this pattern 42 * @internal Exported for testing 43 */ 44export function isLinePrintingCommand( 45 command: string, 46 expressions: string[], 47): boolean { 48 const sedMatch = command.match(/^\s*sed\s+/) 49 if (!sedMatch) return false 50 51 const withoutSed = command.slice(sedMatch[0].length) 52 const parseResult = tryParseShellCommand(withoutSed) 53 if (!parseResult.success) return false 54 const parsed = parseResult.tokens 55 56 // Extract all flags 57 const flags: string[] = [] 58 for (const arg of parsed) { 59 if (typeof arg === 'string' && arg.startsWith('-') && arg !== '--') { 60 flags.push(arg) 61 } 62 } 63 64 // Validate flags - only allow -n, -E, -r, -z and their long forms 65 const allowedFlags = [ 66 '-n', 67 '--quiet', 68 '--silent', 69 '-E', 70 '--regexp-extended', 71 '-r', 72 '-z', 73 '--zero-terminated', 74 '--posix', 75 ] 76 77 if (!validateFlagsAgainstAllowlist(flags, allowedFlags)) { 78 return false 79 } 80 81 // Check if -n flag is present (required for Pattern 1) 82 let hasNFlag = false 83 for (const flag of flags) { 84 if (flag === '-n' || flag === '--quiet' || flag === '--silent') { 85 hasNFlag = true 86 break 87 } 88 // Check in combined flags 89 if (flag.startsWith('-') && !flag.startsWith('--') && flag.includes('n')) { 90 hasNFlag = true 91 break 92 } 93 } 94 95 // Must have -n flag for Pattern 1 96 if (!hasNFlag) { 97 return false 98 } 99 100 // Must have at least one expression 101 if (expressions.length === 0) { 102 return false 103 } 104 105 // All expressions must be print commands (strict allowlist) 106 // Allow semicolon-separated commands 107 for (const expr of expressions) { 108 const commands = expr.split(';') 109 for (const cmd of commands) { 110 if (!isPrintCommand(cmd.trim())) { 111 return false 112 } 113 } 114 } 115 116 return true 117} 118 119/** 120 * Helper: Check if a single command is a valid print command 121 * STRICT ALLOWLIST - only these exact forms are allowed: 122 * - p (print all) 123 * - Np (print line N, where N is digits) 124 * - N,Mp (print lines N through M) 125 * Anything else (including w, W, e, E commands) is rejected. 126 * @internal Exported for testing 127 */ 128export function isPrintCommand(cmd: string): boolean { 129 if (!cmd) return false 130 // Single strict regex that only matches allowed print commands 131 // ^(?:\d+|\d+,\d+)?p$ matches: p, 1p, 123p, 1,5p, 10,200p 132 return /^(?:\d+|\d+,\d+)?p$/.test(cmd) 133} 134 135/** 136 * Pattern 2: Check if this is a substitution command 137 * Allows: sed 's/pattern/replacement/flags' where flags are only: g, p, i, I, m, M, 1-9 138 * When allowFileWrites is true, allows -i flag and file arguments for in-place editing 139 * When allowFileWrites is false (default), requires stdout-only (no file arguments, no -i flag) 140 * @internal Exported for testing 141 */ 142function isSubstitutionCommand( 143 command: string, 144 expressions: string[], 145 hasFileArguments: boolean, 146 options?: { allowFileWrites?: boolean }, 147): boolean { 148 const allowFileWrites = options?.allowFileWrites ?? false 149 150 // When not allowing file writes, must NOT have file arguments 151 if (!allowFileWrites && hasFileArguments) { 152 return false 153 } 154 155 const sedMatch = command.match(/^\s*sed\s+/) 156 if (!sedMatch) return false 157 158 const withoutSed = command.slice(sedMatch[0].length) 159 const parseResult = tryParseShellCommand(withoutSed) 160 if (!parseResult.success) return false 161 const parsed = parseResult.tokens 162 163 // Extract all flags 164 const flags: string[] = [] 165 for (const arg of parsed) { 166 if (typeof arg === 'string' && arg.startsWith('-') && arg !== '--') { 167 flags.push(arg) 168 } 169 } 170 171 // Validate flags based on mode 172 // Base allowed flags for both modes 173 const allowedFlags = ['-E', '--regexp-extended', '-r', '--posix'] 174 175 // When allowing file writes, also permit -i and --in-place 176 if (allowFileWrites) { 177 allowedFlags.push('-i', '--in-place') 178 } 179 180 if (!validateFlagsAgainstAllowlist(flags, allowedFlags)) { 181 return false 182 } 183 184 // Must have exactly one expression 185 if (expressions.length !== 1) { 186 return false 187 } 188 189 const expr = expressions[0]!.trim() 190 191 // STRICT ALLOWLIST: Must be exactly a substitution command starting with 's' 192 // This rejects standalone commands like 'e', 'w file', etc. 193 if (!expr.startsWith('s')) { 194 return false 195 } 196 197 // Parse substitution: s/pattern/replacement/flags 198 // Only allow / as delimiter (strict) 199 const substitutionMatch = expr.match(/^s\/(.*?)$/) 200 if (!substitutionMatch) { 201 return false 202 } 203 204 const rest = substitutionMatch[1]! 205 206 // Find the positions of / delimiters 207 let delimiterCount = 0 208 let lastDelimiterPos = -1 209 let i = 0 210 while (i < rest.length) { 211 if (rest[i] === '\\') { 212 // Skip escaped character 213 i += 2 214 continue 215 } 216 if (rest[i] === '/') { 217 delimiterCount++ 218 lastDelimiterPos = i 219 } 220 i++ 221 } 222 223 // Must have found exactly 2 delimiters (pattern and replacement) 224 if (delimiterCount !== 2) { 225 return false 226 } 227 228 // Extract flags (everything after the last delimiter) 229 const exprFlags = rest.slice(lastDelimiterPos + 1) 230 231 // Validate flags: only allow g, p, i, I, m, M, and optionally ONE digit 1-9 232 const allowedFlagChars = /^[gpimIM]*[1-9]?[gpimIM]*$/ 233 if (!allowedFlagChars.test(exprFlags)) { 234 return false 235 } 236 237 return true 238} 239 240/** 241 * Checks if a sed command is allowed by the allowlist. 242 * The allowlist patterns themselves are strict enough to reject dangerous operations. 243 * @param command The sed command to check 244 * @param options.allowFileWrites When true, allows -i flag and file arguments for substitution commands 245 * @returns true if the command is allowed (matches allowlist and passes denylist check), false otherwise 246 */ 247export function sedCommandIsAllowedByAllowlist( 248 command: string, 249 options?: { allowFileWrites?: boolean }, 250): boolean { 251 const allowFileWrites = options?.allowFileWrites ?? false 252 253 // Extract sed expressions (content inside quotes where actual sed commands live) 254 let expressions: string[] 255 try { 256 expressions = extractSedExpressions(command) 257 } catch (_error) { 258 // If parsing failed, treat as not allowed 259 return false 260 } 261 262 // Check if sed command has file arguments 263 const hasFileArguments = hasFileArgs(command) 264 265 // Check if command matches allowlist patterns 266 let isPattern1 = false 267 let isPattern2 = false 268 269 if (allowFileWrites) { 270 // When allowing file writes, only check substitution commands (Pattern 2 variant) 271 // Pattern 1 (line printing) doesn't need file writes 272 isPattern2 = isSubstitutionCommand(command, expressions, hasFileArguments, { 273 allowFileWrites: true, 274 }) 275 } else { 276 // Standard read-only mode: check both patterns 277 isPattern1 = isLinePrintingCommand(command, expressions) 278 isPattern2 = isSubstitutionCommand(command, expressions, hasFileArguments) 279 } 280 281 if (!isPattern1 && !isPattern2) { 282 return false 283 } 284 285 // Pattern 2 does not allow semicolons (command separators) 286 // Pattern 1 allows semicolons for separating print commands 287 for (const expr of expressions) { 288 if (isPattern2 && expr.includes(';')) { 289 return false 290 } 291 } 292 293 // Defense-in-depth: Even if allowlist matches, check denylist 294 for (const expr of expressions) { 295 if (containsDangerousOperations(expr)) { 296 return false 297 } 298 } 299 300 return true 301} 302 303/** 304 * Check if a sed command has file arguments (not just stdin) 305 * @internal Exported for testing 306 */ 307export function hasFileArgs(command: string): boolean { 308 const sedMatch = command.match(/^\s*sed\s+/) 309 if (!sedMatch) return false 310 311 const withoutSed = command.slice(sedMatch[0].length) 312 const parseResult = tryParseShellCommand(withoutSed) 313 if (!parseResult.success) return true 314 const parsed = parseResult.tokens 315 316 try { 317 let argCount = 0 318 let hasEFlag = false 319 320 for (let i = 0; i < parsed.length; i++) { 321 const arg = parsed[i] 322 323 // Handle both string arguments and glob patterns (like *.log) 324 if (typeof arg !== 'string' && typeof arg !== 'object') continue 325 326 // If it's a glob pattern, it counts as a file argument 327 if ( 328 typeof arg === 'object' && 329 arg !== null && 330 'op' in arg && 331 arg.op === 'glob' 332 ) { 333 return true 334 } 335 336 // Skip non-string arguments that aren't glob patterns 337 if (typeof arg !== 'string') continue 338 339 // Handle -e flag followed by expression 340 if ((arg === '-e' || arg === '--expression') && i + 1 < parsed.length) { 341 hasEFlag = true 342 i++ // Skip the next argument since it's the expression 343 continue 344 } 345 346 // Handle --expression=value format 347 if (arg.startsWith('--expression=')) { 348 hasEFlag = true 349 continue 350 } 351 352 // Handle -e=value format (non-standard but defense in depth) 353 if (arg.startsWith('-e=')) { 354 hasEFlag = true 355 continue 356 } 357 358 // Skip other flags 359 if (arg.startsWith('-')) continue 360 361 argCount++ 362 363 // If we used -e flags, ALL non-flag arguments are file arguments 364 if (hasEFlag) { 365 return true 366 } 367 368 // If we didn't use -e flags, the first non-flag argument is the sed expression, 369 // so we need more than 1 non-flag argument to have file arguments 370 if (argCount > 1) { 371 return true 372 } 373 } 374 375 return false 376 } catch (_error) { 377 return true // Assume dangerous if parsing fails 378 } 379} 380 381/** 382 * Extract sed expressions from command, ignoring flags and filenames 383 * @param command Full sed command 384 * @returns Array of sed expressions to check for dangerous operations 385 * @throws Error if parsing fails 386 * @internal Exported for testing 387 */ 388export function extractSedExpressions(command: string): string[] { 389 const expressions: string[] = [] 390 391 // Calculate withoutSed by trimming off the first N characters (removing 'sed ') 392 const sedMatch = command.match(/^\s*sed\s+/) 393 if (!sedMatch) return expressions 394 395 const withoutSed = command.slice(sedMatch[0].length) 396 397 // Reject dangerous flag combinations like -ew, -eW, -ee, -we (combined -e/-w with dangerous commands) 398 if (/-e[wWe]/.test(withoutSed) || /-w[eE]/.test(withoutSed)) { 399 throw new Error('Dangerous flag combination detected') 400 } 401 402 // Use shell-quote to parse the arguments properly 403 const parseResult = tryParseShellCommand(withoutSed) 404 if (!parseResult.success) { 405 // Malformed shell syntax - throw error to be caught by caller 406 throw new Error(`Malformed shell syntax: ${parseResult.error}`) 407 } 408 const parsed = parseResult.tokens 409 try { 410 let foundEFlag = false 411 let foundExpression = false 412 413 for (let i = 0; i < parsed.length; i++) { 414 const arg = parsed[i] 415 416 // Skip non-string arguments (like control operators) 417 if (typeof arg !== 'string') continue 418 419 // Handle -e flag followed by expression 420 if ((arg === '-e' || arg === '--expression') && i + 1 < parsed.length) { 421 foundEFlag = true 422 const nextArg = parsed[i + 1] 423 if (typeof nextArg === 'string') { 424 expressions.push(nextArg) 425 i++ // Skip the next argument since we consumed it 426 } 427 continue 428 } 429 430 // Handle --expression=value format 431 if (arg.startsWith('--expression=')) { 432 foundEFlag = true 433 expressions.push(arg.slice('--expression='.length)) 434 continue 435 } 436 437 // Handle -e=value format (non-standard but defense in depth) 438 if (arg.startsWith('-e=')) { 439 foundEFlag = true 440 expressions.push(arg.slice('-e='.length)) 441 continue 442 } 443 444 // Skip other flags 445 if (arg.startsWith('-')) continue 446 447 // If we haven't found any -e flags, the first non-flag argument is the sed expression 448 if (!foundEFlag && !foundExpression) { 449 expressions.push(arg) 450 foundExpression = true 451 continue 452 } 453 454 // If we've already found -e flags or a standalone expression, 455 // remaining non-flag arguments are filenames 456 break 457 } 458 } catch (error) { 459 // If shell-quote parsing fails, treat the sed command as unsafe 460 throw new Error( 461 `Failed to parse sed command: ${error instanceof Error ? error.message : 'Unknown error'}`, 462 ) 463 } 464 465 return expressions 466} 467 468/** 469 * Check if a sed expression contains dangerous operations (denylist) 470 * @param expression Single sed expression (without quotes) 471 * @returns true if dangerous, false if safe 472 */ 473function containsDangerousOperations(expression: string): boolean { 474 const cmd = expression.trim() 475 if (!cmd) return false 476 477 // CONSERVATIVE REJECTIONS: Broadly reject patterns that could be dangerous 478 // When in doubt, treat as unsafe 479 480 // Reject non-ASCII characters (Unicode homoglyphs, combining chars, etc.) 481 // Examples: w (fullwidth), ᴡ (small capital), w̃ (combining tilde) 482 // Check for characters outside ASCII range (0x01-0x7F, excluding null byte) 483 // eslint-disable-next-line no-control-regex 484 if (/[^\x01-\x7F]/.test(cmd)) { 485 return true 486 } 487 488 // Reject curly braces (blocks) - too complex to parse 489 if (cmd.includes('{') || cmd.includes('}')) { 490 return true 491 } 492 493 // Reject newlines - multi-line commands are too complex 494 if (cmd.includes('\n')) { 495 return true 496 } 497 498 // Reject comments (# not immediately after s command) 499 // Comments look like: #comment or start with # 500 // Delimiter looks like: s#pattern#replacement# 501 const hashIndex = cmd.indexOf('#') 502 if (hashIndex !== -1 && !(hashIndex > 0 && cmd[hashIndex - 1] === 's')) { 503 return true 504 } 505 506 // Reject negation operator 507 // Negation can appear: at start (!/pattern/), after address (/pattern/!, 1,10!, $!) 508 // Delimiter looks like: s!pattern!replacement! (has 's' before it) 509 if (/^!/.test(cmd) || /[/\d$]!/.test(cmd)) { 510 return true 511 } 512 513 // Reject tilde in GNU step address format (digit~digit, ,~digit, or $~digit) 514 // Allow whitespace around tilde 515 if (/\d\s*~\s*\d|,\s*~\s*\d|\$\s*~\s*\d/.test(cmd)) { 516 return true 517 } 518 519 // Reject comma at start (bare comma is shorthand for 1,$ address range) 520 if (/^,/.test(cmd)) { 521 return true 522 } 523 524 // Reject comma followed by +/- (GNU offset addresses) 525 if (/,\s*[+-]/.test(cmd)) { 526 return true 527 } 528 529 // Reject backslash tricks: 530 // 1. s\ (substitution with backslash delimiter) 531 // 2. \X where X could be an alternate delimiter (|, #, %, etc.) - not regex escapes 532 if (/s\\/.test(cmd) || /\\[|#%@]/.test(cmd)) { 533 return true 534 } 535 536 // Reject escaped slashes followed by w/W (patterns like /\/path\/to\/file/w) 537 if (/\\\/.*[wW]/.test(cmd)) { 538 return true 539 } 540 541 // Reject malformed/suspicious patterns we don't understand 542 // If there's a slash followed by non-slash chars, then whitespace, then dangerous commands 543 // Examples: /pattern w file, /pattern e cmd, /foo X;w file 544 if (/\/[^/]*\s+[wWeE]/.test(cmd)) { 545 return true 546 } 547 548 // Reject malformed substitution commands that don't follow normal pattern 549 // Examples: s/foobareoutput.txt (missing delimiters), s/foo/bar//w (extra delimiter) 550 if (/^s\//.test(cmd) && !/^s\/[^/]*\/[^/]*\/[^/]*$/.test(cmd)) { 551 return true 552 } 553 554 // PARANOID: Reject any command starting with 's' that ends with dangerous chars (w, W, e, E) 555 // and doesn't match our known safe substitution pattern. This catches malformed s commands 556 // with non-slash delimiters that might be trying to use dangerous flags. 557 if (/^s./.test(cmd) && /[wWeE]$/.test(cmd)) { 558 // Check if it's a properly formed substitution (any delimiter, not just /) 559 const properSubst = /^s([^\\\n]).*?\1.*?\1[^wWeE]*$/.test(cmd) 560 if (!properSubst) { 561 return true 562 } 563 } 564 565 // Check for dangerous write commands 566 // Patterns: [address]w filename, [address]W filename, /pattern/w filename, /pattern/W filename 567 // Simplified to avoid exponential backtracking (CodeQL issue) 568 // Check for w/W in contexts where it would be a command (with optional whitespace) 569 if ( 570 /^[wW]\s*\S+/.test(cmd) || // At start: w file 571 /^\d+\s*[wW]\s*\S+/.test(cmd) || // After line number: 1w file or 1 w file 572 /^\$\s*[wW]\s*\S+/.test(cmd) || // After $: $w file or $ w file 573 /^\/[^/]*\/[IMim]*\s*[wW]\s*\S+/.test(cmd) || // After pattern: /pattern/w file 574 /^\d+,\d+\s*[wW]\s*\S+/.test(cmd) || // After range: 1,10w file 575 /^\d+,\$\s*[wW]\s*\S+/.test(cmd) || // After range: 1,$w file 576 /^\/[^/]*\/[IMim]*,\/[^/]*\/[IMim]*\s*[wW]\s*\S+/.test(cmd) // After pattern range: /s/,/e/w file 577 ) { 578 return true 579 } 580 581 // Check for dangerous execute commands 582 // Patterns: [address]e [command], /pattern/e [command], or commands starting with e 583 // Simplified to avoid exponential backtracking (CodeQL issue) 584 // Check for e in contexts where it would be a command (with optional whitespace) 585 if ( 586 /^e/.test(cmd) || // At start: e cmd 587 /^\d+\s*e/.test(cmd) || // After line number: 1e or 1 e 588 /^\$\s*e/.test(cmd) || // After $: $e or $ e 589 /^\/[^/]*\/[IMim]*\s*e/.test(cmd) || // After pattern: /pattern/e 590 /^\d+,\d+\s*e/.test(cmd) || // After range: 1,10e 591 /^\d+,\$\s*e/.test(cmd) || // After range: 1,$e 592 /^\/[^/]*\/[IMim]*,\/[^/]*\/[IMim]*\s*e/.test(cmd) // After pattern range: /s/,/e/e 593 ) { 594 return true 595 } 596 597 // Check for substitution commands with dangerous flags 598 // Pattern: s<delim>pattern<delim>replacement<delim>flags where flags contain w or e 599 // Per POSIX, sed allows any character except backslash and newline as delimiter 600 const substitutionMatch = cmd.match(/s([^\\\n]).*?\1.*?\1(.*?)$/) 601 if (substitutionMatch) { 602 const flags = substitutionMatch[2] || '' 603 604 // Check for write flag: s/old/new/w filename or s/old/new/gw filename 605 if (flags.includes('w') || flags.includes('W')) { 606 return true 607 } 608 609 // Check for execute flag: s/old/new/e or s/old/new/ge 610 if (flags.includes('e') || flags.includes('E')) { 611 return true 612 } 613 } 614 615 // Check for y (transliterate) command followed by dangerous operations 616 // Pattern: y<delim>source<delim>dest<delim> followed by anything 617 // The y command uses same delimiter syntax as s command 618 // PARANOID: Reject any y command that has w/W/e/E anywhere after the delimiters 619 const yCommandMatch = cmd.match(/y([^\\\n])/) 620 if (yCommandMatch) { 621 // If we see a y command, check if there's any w, W, e, or E in the entire command 622 // This is paranoid but safe - y commands are rare and w/e after y is suspicious 623 if (/[wWeE]/.test(cmd)) { 624 return true 625 } 626 } 627 628 return false 629} 630 631/** 632 * Cross-cutting validation step for sed commands. 633 * 634 * This is a constraint check that blocks dangerous sed operations regardless of mode. 635 * It returns 'passthrough' for non-sed commands or safe sed commands, 636 * and 'ask' for dangerous sed operations (w/W/e/E commands). 637 * 638 * @param input - Object containing the command string 639 * @param toolPermissionContext - Context containing mode and permissions 640 * @returns 641 * - 'ask' if any sed command contains dangerous operations 642 * - 'passthrough' if no sed commands or all are safe 643 */ 644export function checkSedConstraints( 645 input: { command: string }, 646 toolPermissionContext: ToolPermissionContext, 647): PermissionResult { 648 const commands = splitCommand_DEPRECATED(input.command) 649 650 for (const cmd of commands) { 651 // Skip non-sed commands 652 const trimmed = cmd.trim() 653 const baseCmd = trimmed.split(/\s+/)[0] 654 if (baseCmd !== 'sed') { 655 continue 656 } 657 658 // In acceptEdits mode, allow file writes (-i flag) but still block dangerous operations 659 const allowFileWrites = toolPermissionContext.mode === 'acceptEdits' 660 661 const isAllowed = sedCommandIsAllowedByAllowlist(trimmed, { 662 allowFileWrites, 663 }) 664 665 if (!isAllowed) { 666 return { 667 behavior: 'ask', 668 message: 669 'sed command requires approval (contains potentially dangerous operations)', 670 decisionReason: { 671 type: 'other', 672 reason: 673 'sed command contains operations that require explicit approval (e.g., write commands, execute commands)', 674 }, 675 } 676 } 677 } 678 679 // No dangerous sed commands found (or no sed commands at all) 680 return { 681 behavior: 'passthrough', 682 message: 'No dangerous sed operations detected', 683 } 684}