Files
zk-data-agent/src/bash_security.py
T
Abdelrahman Abdallah aacf0a212a Created src/prompt_constants.py (550+ lines) porting all constants from npm src/constants/:
┌──────────────────┬───────────────────────────┬───────────────────────────────────────────────┐
│ Category         │ npm Source                │ Items                                         │
├──────────────────┼───────────────────────────┼───────────────────────────────────────────────┤
│ Product metadata │ product.ts                │ URLs, base URLs                               │
├──────────────────┼───────────────────────────┼───────────────────────────────────────────────┤
│ System prefixes  │ system.ts                 │ 3 prompt prefixes                             │
├──────────────────┼───────────────────────────┼───────────────────────────────────────────────┤
│ Cyber risk       │ cyberRiskInstruction.ts   │ Safety instruction                            │
├──────────────────┼───────────────────────────┼───────────────────────────────────────────────┤
│ API limits       │ apiLimits.ts              │ 10 image/PDF/media limits                     │
├──────────────────┼───────────────────────────┼───────────────────────────────────────────────┤
│ Tool limits      │ toolLimits.ts             │ 6 result size constants                       │
├──────────────────┼───────────────────────────┼───────────────────────────────────────────────┤
│ Spinner verbs    │ spinnerVerbs.ts           │ 187 whimsical gerunds                         │
├──────────────────┼───────────────────────────┼───────────────────────────────────────────────┤
│ Completion verbs │ turnCompletionVerbs.ts    │ 8 past-tense verbs                            │
├──────────────────┼───────────────────────────┼───────────────────────────────────────────────┤
│ Figures/symbols  │ figures.ts                │ 25 Unicode UI symbols                         │
├──────────────────┼───────────────────────────┼───────────────────────────────────────────────┤
│ XML tags         │ xml.ts                    │ 30+ tag constants                             │
├──────────────────┼───────────────────────────┼───────────────────────────────────────────────┤
│ Messages         │ messages.ts               │ NO_CONTENT_MESSAGE                            │
├──────────────────┼───────────────────────────┼───────────────────────────────────────────────┤
│ Date utilities   │ common.ts                 │ 4 functions                                   │
├──────────────────┼───────────────────────────┼───────────────────────────────────────────────┤
│ Section caching  │ systemPromptSections.ts   │ Memoized/volatile sections                    │
├──────────────────┼───────────────────────────┼───────────────────────────────────────────────┤
│ Output styles    │ outputStyles.ts           │ 3 built-in configs                            │
├──────────────────┼───────────────────────────┼───────────────────────────────────────────────┤
│ Prompt helpers   │ prompts.ts                │ Knowledge cutoff, language, scratchpad, hooks │
└──────────────────┴───────────────────────────┴───────────────────────────────────────────────┘

91 new tests in tests/test_prompt_constants.py. All 17 SQL todos done.
2026-04-08 00:02:53 +02:00

1262 lines
45 KiB
Python

"""
BashTool security validation module.
Ported from npm src/tools/BashTool/bashSecurity.ts and related files.
Provides comprehensive command validation to detect and block dangerous
shell commands, injection patterns, and obfuscation techniques.
The main entry point is `bash_command_is_safe(command)` which returns a
SecurityResult indicating whether the command should be allowed, blocked,
or needs user confirmation.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from enum import Enum
from typing import Callable, Optional
# ---------------------------------------------------------------------------
# Types
# ---------------------------------------------------------------------------
class SecurityBehavior(Enum):
"""Possible outcomes of a security check."""
ALLOW = 'allow' # Command is safe, allow without asking
ASK = 'ask' # Command needs user confirmation
DENY = 'deny' # Command is outright blocked
PASSTHROUGH = 'passthrough' # Check has no opinion, continue to next
@dataclass(frozen=True)
class SecurityResult:
"""Result of a security validation check."""
behavior: SecurityBehavior
message: str
is_misparsing: bool = False # For misparsing-specific concerns
def _allow(message: str = 'Command allowed') -> SecurityResult:
return SecurityResult(SecurityBehavior.ALLOW, message)
def _ask(message: str, *, misparsing: bool = False) -> SecurityResult:
return SecurityResult(SecurityBehavior.ASK, message, is_misparsing=misparsing)
def _deny(message: str) -> SecurityResult:
return SecurityResult(SecurityBehavior.DENY, message)
def _passthrough(message: str = '') -> SecurityResult:
return SecurityResult(SecurityBehavior.PASSTHROUGH, message)
# ---------------------------------------------------------------------------
# Command substitution patterns (from npm bashSecurity.ts)
# ---------------------------------------------------------------------------
COMMAND_SUBSTITUTION_PATTERNS: list[tuple[re.Pattern[str], str]] = [
(re.compile(r'<\('), 'process substitution <()'),
(re.compile(r'>\('), 'process substitution >()'),
(re.compile(r'=\('), 'Zsh process substitution =()'),
(re.compile(r'(?:^|[\s;&|])=[a-zA-Z_]'), 'Zsh equals expansion (=cmd)'),
(re.compile(r'\$\('), '$() command substitution'),
(re.compile(r'\$\{'), '${} parameter substitution'),
(re.compile(r'\$\['), '$[] legacy arithmetic expansion'),
(re.compile(r'~\['), 'Zsh-style parameter expansion'),
(re.compile(r'\(e:'), 'Zsh-style glob qualifiers'),
(re.compile(r'\(\+'), 'Zsh glob qualifier with command execution'),
(re.compile(r'\}\s*always\s*\{'), 'Zsh always block (try/always construct)'),
(re.compile(r'<#'), 'PowerShell comment syntax'),
]
# Zsh dangerous commands that bypass security checks
ZSH_DANGEROUS_COMMANDS = frozenset({
'zmodload', 'emulate',
'sysopen', 'sysread', 'syswrite', 'sysseek',
'zpty', 'ztcp', 'zsocket', 'mapfile',
'zf_rm', 'zf_mv', 'zf_ln', 'zf_chmod', 'zf_chown',
'zf_mkdir', 'zf_rmdir', 'zf_chgrp',
})
ZSH_PRECOMMAND_MODIFIERS = frozenset({
'command', 'builtin', 'noglob', 'nocorrect',
})
# Control characters (0x00-0x08, 0x0B-0x0C, 0x0E-0x1F, 0x7F)
# Excludes tab (0x09), newline (0x0A), carriage return (0x0D)
CONTROL_CHAR_RE = re.compile(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]')
# Unicode whitespace that could cause parser differentials
UNICODE_WS_RE = re.compile(
r'[\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]'
)
# ---------------------------------------------------------------------------
# Destructive command patterns (from npm destructiveCommandWarning.ts)
# ---------------------------------------------------------------------------
DESTRUCTIVE_PATTERNS: list[tuple[re.Pattern[str], str]] = [
# Git - data loss / hard to reverse
(re.compile(r'\bgit\s+reset\s+--hard\b'),
'Note: may discard uncommitted changes'),
(re.compile(r'\bgit\s+push\b[^;&|\n]*[ \t](--force|--force-with-lease|-f)\b'),
'Note: may overwrite remote history'),
(re.compile(r'\bgit\s+clean\b(?![^;&|\n]*(?:-[a-zA-Z]*n|--dry-run))[^;&|\n]*-[a-zA-Z]*f'),
'Note: may permanently delete untracked files'),
(re.compile(r'\bgit\s+checkout\s+(--\s+)?\.[ \t]*($|[;&|\n])'),
'Note: may discard all working tree changes'),
(re.compile(r'\bgit\s+restore\s+(--\s+)?\.[ \t]*($|[;&|\n])'),
'Note: may discard all working tree changes'),
(re.compile(r'\bgit\s+stash[ \t]+(drop|clear)\b'),
'Note: may permanently remove stashed changes'),
(re.compile(r'\bgit\s+branch\s+(-D[ \t]|--delete\s+--force|--force\s+--delete)\b'),
'Note: may force-delete a branch'),
# Git - safety bypass
(re.compile(r'\bgit\s+(commit|push|merge)\b[^;&|\n]*--no-verify\b'),
'Note: may skip safety hooks'),
(re.compile(r'\bgit\s+commit\b[^;&|\n]*--amend\b'),
'Note: may rewrite the last commit'),
# File deletion
(re.compile(r'(^|[;&|\n]\s*)rm\s+-[a-zA-Z]*[rR][a-zA-Z]*f|(^|[;&|\n]\s*)rm\s+-[a-zA-Z]*f[a-zA-Z]*[rR]'),
'Note: may recursively force-remove files'),
(re.compile(r'(^|[;&|\n]\s*)rm\s+-[a-zA-Z]*[rR]'),
'Note: may recursively remove files'),
(re.compile(r'(^|[;&|\n]\s*)rm\s+-[a-zA-Z]*f'),
'Note: may force-remove files'),
# Database
(re.compile(r'\b(DROP|TRUNCATE)\s+(TABLE|DATABASE|SCHEMA)\b', re.IGNORECASE),
'Note: may drop or truncate database objects'),
(re.compile(r'\bDELETE\s+FROM\s+\w+[ \t]*(;|"|\'|\n|$)', re.IGNORECASE),
'Note: may delete all rows from a database table'),
# Infrastructure
(re.compile(r'\bkubectl\s+delete\b'),
'Note: may delete Kubernetes resources'),
(re.compile(r'\bterraform\s+destroy\b'),
'Note: may destroy Terraform infrastructure'),
]
# ---------------------------------------------------------------------------
# Command semantics (from npm commandSemantics.ts)
# ---------------------------------------------------------------------------
CommandSemantic = Callable[[int, str, str], tuple[bool, Optional[str]]]
def _default_semantic(exit_code: int, _stdout: str, _stderr: str) -> tuple[bool, Optional[str]]:
return (exit_code != 0, f'Command failed with exit code {exit_code}' if exit_code != 0 else None)
def _grep_semantic(exit_code: int, _stdout: str, _stderr: str) -> tuple[bool, Optional[str]]:
return (exit_code >= 2, 'No matches found' if exit_code == 1 else None)
def _find_semantic(exit_code: int, _stdout: str, _stderr: str) -> tuple[bool, Optional[str]]:
return (exit_code >= 2, 'Some directories were inaccessible' if exit_code == 1 else None)
def _diff_semantic(exit_code: int, _stdout: str, _stderr: str) -> tuple[bool, Optional[str]]:
return (exit_code >= 2, 'Files differ' if exit_code == 1 else None)
def _test_semantic(exit_code: int, _stdout: str, _stderr: str) -> tuple[bool, Optional[str]]:
return (exit_code >= 2, 'Condition is false' if exit_code == 1 else None)
COMMAND_SEMANTICS: dict[str, CommandSemantic] = {
'grep': _grep_semantic,
'rg': _grep_semantic,
'find': _find_semantic,
'diff': _diff_semantic,
'test': _test_semantic,
'[': _test_semantic,
}
def interpret_command_result(
command: str,
exit_code: int,
stdout: str,
stderr: str,
) -> tuple[bool, Optional[str]]:
"""
Interpret command result based on semantic rules.
Returns (is_error, optional_message).
"""
# Extract base command (last command in pipeline determines exit code)
segments = split_command(command)
last_segment = segments[-1] if segments else command
base = last_segment.strip().split()[0] if last_segment.strip() else ''
semantic = COMMAND_SEMANTICS.get(base, _default_semantic)
return semantic(exit_code, stdout, stderr)
# ---------------------------------------------------------------------------
# Read-only command detection (from npm readOnlyValidation.ts)
# ---------------------------------------------------------------------------
READ_ONLY_COMMANDS = frozenset({
# File viewing
'cat', 'head', 'tail', 'less', 'more', 'bat', 'batcat',
# Searching
'grep', 'egrep', 'fgrep', 'rg', 'ag', 'ack',
# File listing
'ls', 'll', 'la', 'dir', 'tree', 'exa', 'eza',
# File info
'stat', 'file', 'wc', 'du', 'df', 'readlink', 'realpath',
'md5sum', 'sha256sum', 'sha1sum',
# System info
'uname', 'hostname', 'whoami', 'id', 'groups', 'date',
'uptime', 'free', 'lsb_release', 'arch', 'nproc',
# Process info
'ps', 'top', 'htop', 'pgrep',
# Printing
'echo', 'printf', 'true', 'false',
# Path tools
'which', 'whereis', 'type', 'command', 'hash',
'dirname', 'basename', 'pwd',
# Text processing (non-destructive)
'sort', 'uniq', 'cut', 'tr', 'fold', 'fmt', 'nl', 'rev',
'column', 'paste', 'join', 'comm', 'tee',
'awk', 'gawk', 'mawk',
'sed', # read-only only when no -i flag
'diff', 'cmp', 'colordiff',
# Encoding
'base64', 'xxd', 'od', 'hexdump',
# Find (read-only without -exec/-delete)
'find', 'fd', 'fdfind', 'locate', 'mlocate',
# Version/help
'man', 'info', 'help',
# Env inspection
'env', 'printenv', 'set',
# Network inspection (read-only)
'ping', 'dig', 'nslookup', 'host', 'ifconfig', 'ip',
# Git read-only
'git', # only certain subcommands are read-only
# Python/Node read-only
'python', 'python3', 'node', # only with certain flags
})
GIT_READ_ONLY_SUBCOMMANDS = frozenset({
'status', 'log', 'diff', 'show', 'branch', 'remote', 'tag',
'describe', 'rev-parse', 'rev-list', 'ls-files', 'ls-tree',
'ls-remote', 'cat-file', 'name-rev', 'shortlog', 'blame',
'grep', 'reflog', 'stash list', 'config', 'version',
})
# Flags that make find dangerous (not read-only)
FIND_DANGEROUS_FLAGS = frozenset({
'-exec', '-execdir', '-ok', '-okdir', '-delete',
})
def is_command_read_only(command: str) -> bool:
"""
Check if a command is read-only (doesn't modify the filesystem).
Returns True if the command is safe to auto-approve.
"""
stripped = command.strip()
if not stripped:
return True
parts = stripped.split()
base_cmd = parts[0]
if base_cmd not in READ_ONLY_COMMANDS:
return False
# Special handling for git
if base_cmd == 'git':
if len(parts) < 2:
return True # bare 'git' is safe
subcmd = parts[1]
return subcmd in GIT_READ_ONLY_SUBCOMMANDS
# Special handling for sed (only read-only without -i)
if base_cmd == 'sed':
return '-i' not in parts and '--in-place' not in parts
# Special handling for find (no -exec, -delete, etc.)
if base_cmd in ('find', 'fd', 'fdfind'):
return not any(flag in parts for flag in FIND_DANGEROUS_FLAGS)
# Special handling for python/node (only with -c, --version, --help)
if base_cmd in ('python', 'python3', 'node'):
safe_flags = {'-c', '--version', '--help', '-V', '-h'}
return len(parts) >= 2 and parts[1] in safe_flags
return True
# ---------------------------------------------------------------------------
# Helper: Quote-aware content extraction (from npm bashSecurity.ts)
# ---------------------------------------------------------------------------
def extract_quoted_content(command: str) -> tuple[str, str, str]:
"""
Extract content outside different quoting levels.
Returns (with_double_quotes, fully_unquoted, unquoted_keep_quote_chars).
- with_double_quotes: content outside single quotes (double-quoted content preserved)
- fully_unquoted: content outside both single AND double quotes
- unquoted_keep_quote_chars: like fully_unquoted but preserves ' and " chars
"""
with_double_quotes = ''
fully_unquoted = ''
unquoted_keep_quote_chars = ''
in_single_quote = False
in_double_quote = False
escaped = False
for i, char in enumerate(command):
if escaped:
escaped = False
if not in_single_quote:
with_double_quotes += char
if not in_single_quote and not in_double_quote:
fully_unquoted += char
unquoted_keep_quote_chars += char
continue
if char == '\\' and not in_single_quote:
escaped = True
if not in_single_quote:
with_double_quotes += char
if not in_single_quote and not in_double_quote:
fully_unquoted += char
unquoted_keep_quote_chars += char
continue
if char == "'" and not in_double_quote:
in_single_quote = not in_single_quote
unquoted_keep_quote_chars += char
continue
if char == '"' and not in_single_quote:
in_double_quote = not in_double_quote
unquoted_keep_quote_chars += char
continue
if not in_single_quote:
with_double_quotes += char
if not in_single_quote and not in_double_quote:
fully_unquoted += char
unquoted_keep_quote_chars += char
return with_double_quotes, fully_unquoted, unquoted_keep_quote_chars
def strip_safe_redirections(content: str) -> str:
"""Strip safe redirections (>/dev/null, 2>&1, </dev/null) from content."""
result = content
result = re.sub(r'\s+2\s*>&\s*1(?=\s|$)', '', result)
result = re.sub(r'[012]?\s*>\s*/dev/null(?=\s|$)', '', result)
result = re.sub(r'\s*<\s*/dev/null(?=\s|$)', '', result)
return result
def has_unescaped_char(content: str, char: str) -> bool:
"""Check if content contains an unescaped occurrence of a single character."""
assert len(char) == 1, 'has_unescaped_char only works with single characters'
i = 0
while i < len(content):
if content[i] == '\\' and i + 1 < len(content):
i += 2 # Skip backslash and escaped character
continue
if content[i] == char:
return True
i += 1
return False
# ---------------------------------------------------------------------------
# Simple command splitter (for splitting on &&, ||, ;, |)
# ---------------------------------------------------------------------------
def split_command(command: str) -> list[str]:
"""
Split a compound command on operators (&&, ||, ;, |) while respecting quotes.
Returns list of individual command strings.
"""
segments: list[str] = []
current = ''
in_single_quote = False
in_double_quote = False
escaped = False
i = 0
while i < len(command):
c = command[i]
if escaped:
escaped = False
current += c
i += 1
continue
if c == '\\' and not in_single_quote:
escaped = True
current += c
i += 1
continue
if c == "'" and not in_double_quote:
in_single_quote = not in_single_quote
current += c
i += 1
continue
if c == '"' and not in_single_quote:
in_double_quote = not in_double_quote
current += c
i += 1
continue
if in_single_quote or in_double_quote:
current += c
i += 1
continue
# Check for operators
if c == ';':
segments.append(current)
current = ''
i += 1
continue
if c == '|':
if i + 1 < len(command) and command[i + 1] == '|':
segments.append(current)
current = ''
i += 2
continue
else:
segments.append(current)
current = ''
i += 1
continue
if c == '&':
if i + 1 < len(command) and command[i + 1] == '&':
segments.append(current)
current = ''
i += 2
continue
else:
current += c
i += 1
continue
current += c
i += 1
if current:
segments.append(current)
return [s.strip() for s in segments if s.strip()]
# ---------------------------------------------------------------------------
# Validation context (matching npm ValidationContext)
# ---------------------------------------------------------------------------
@dataclass
class ValidationContext:
"""Context passed to each validator function."""
original_command: str
base_command: str
unquoted_content: str # Outside single quotes
fully_unquoted_content: str # Outside all quotes, with safe redirections stripped
fully_unquoted_pre_strip: str # Outside all quotes, before stripping redirections
unquoted_keep_quote_chars: str # Like fully_unquoted but preserves quote characters
# ---------------------------------------------------------------------------
# Validators (ported from npm bashSecurity.ts)
# ---------------------------------------------------------------------------
def validate_empty(ctx: ValidationContext) -> SecurityResult:
"""Allow empty commands."""
if not ctx.original_command.strip():
return _allow('Empty command is safe')
return _passthrough('Command is not empty')
def validate_control_characters(ctx: ValidationContext) -> SecurityResult:
"""Block commands with non-printable control characters."""
if CONTROL_CHAR_RE.search(ctx.original_command):
return _ask(
'Command contains non-printable control characters that could bypass security checks',
misparsing=True,
)
return _passthrough()
def validate_incomplete_commands(ctx: ValidationContext) -> SecurityResult:
"""Detect incomplete command fragments."""
original = ctx.original_command
trimmed = original.strip()
if re.match(r'^\s*\t', original):
return _ask('Command appears to be an incomplete fragment (starts with tab)',
misparsing=True)
if trimmed.startswith('-'):
return _ask('Command appears to be an incomplete fragment (starts with flags)',
misparsing=True)
if re.match(r'^\s*(&&|\|\||;|>>?|<)', original):
return _ask('Command appears to be a continuation line (starts with operator)',
misparsing=True)
return _passthrough()
def validate_git_commit(ctx: ValidationContext) -> SecurityResult:
"""
Early-allow simple git commit -m '...' commands.
Block git commit messages with command substitution.
"""
if ctx.base_command != 'git' or not re.match(r'^git\s+commit\s+', ctx.original_command):
return _passthrough()
if '\\' in ctx.original_command:
return _passthrough('Git commit contains backslash, needs full validation')
msg_match = re.match(
r'^git[ \t]+commit[ \t]+[^;&|`$<>()\n\r]*?-m[ \t]+(["\'])([\s\S]*?)\1(.*)$',
ctx.original_command,
)
if msg_match:
quote, message_content, remainder = msg_match.group(1), msg_match.group(2), msg_match.group(3)
if quote == '"' and message_content and re.search(r'\$\(|`|\$\{', message_content):
return _ask('Git commit message contains command substitution patterns',
misparsing=True)
if remainder and re.search(r'[;|&()`]|\$\(|\$\{', remainder):
return _passthrough('Git commit remainder contains shell metacharacters')
if remainder:
# Check for unquoted redirect operators
unquoted = ''
in_sq = False
in_dq = False
for c in remainder:
if c == "'" and not in_dq:
in_sq = not in_sq
continue
if c == '"' and not in_sq:
in_dq = not in_dq
continue
if not in_sq and not in_dq:
unquoted += c
if re.search(r'[<>]', unquoted):
return _passthrough('Git commit remainder contains unquoted redirect operator')
if message_content and message_content.startswith('-'):
return _ask('Command contains quoted characters in flag names',
misparsing=True)
return _allow('Git commit with simple quoted message is allowed')
return _passthrough('Git commit needs validation')
def validate_jq_command(ctx: ValidationContext) -> SecurityResult:
"""Block dangerous jq patterns (system(), -f, --from-file, etc.)."""
if ctx.base_command != 'jq':
return _passthrough()
if re.search(r'\bsystem\s*\(', ctx.original_command):
return _ask('jq command contains system() function which executes arbitrary commands')
after_jq = ctx.original_command[3:].strip() if len(ctx.original_command) > 3 else ''
if re.search(r'(?:^|\s)(?:-f\b|--from-file|--rawfile|--slurpfile|-L\b|--library-path)', after_jq):
return _ask('jq command contains dangerous flags that could read arbitrary files')
return _passthrough()
def validate_obfuscated_flags(ctx: ValidationContext) -> SecurityResult:
"""Detect shell quoting bypass patterns used to circumvent security checks."""
original = ctx.original_command
base = ctx.base_command
# Echo is safe for obfuscated flags without shell operators
if base == 'echo' and not re.search(r'[|&;]', original):
return _passthrough()
# Block ANSI-C quoting ($'...')
if re.search(r"\$'[^']*'", original):
return _ask('Command contains ANSI-C quoting which can hide characters')
# Block locale quoting ($"...")
if re.search(r'\$"[^"]*"', original):
return _ask('Command contains locale quoting which can hide characters')
# Block empty ANSI-C or locale quotes followed by dash
if re.search(r"""\$['"]{2}\s*-""", original):
return _ask('Command contains empty special quotes before dash (potential bypass)')
# Block sequence of empty quotes followed by dash
if re.search(r"""(?:^|\s)(?:''|""){1,}\s*-""", original):
return _ask('Command contains empty quotes before dash (potential bypass)')
# Block homogeneous empty quote pairs adjacent to quoted dash
if re.search(r"""(?:""|''){1,}['"][-]""", original):
return _ask('Command contains empty quote pair adjacent to quoted dash')
# Block 3+ consecutive quotes at word start
if re.search(r"""(?:^|\s)['\"]{3,}""", original):
return _ask('Command contains consecutive quote characters at word start')
# Track quote state and detect quoted flags
in_single_quote = False
in_double_quote = False
escaped = False
for i in range(len(original) - 1):
c = original[i]
next_c = original[i + 1]
if escaped:
escaped = False
continue
if c == '\\' and not in_single_quote:
escaped = True
continue
if c == "'" and not in_double_quote:
in_single_quote = not in_single_quote
continue
if c == '"' and not in_single_quote:
in_double_quote = not in_double_quote
continue
if in_single_quote or in_double_quote:
continue
# Look for whitespace followed by quote that contains a dash (obfuscated flag)
if c and c in ' \t\n' and next_c in "'\"`":
quote_char = next_c
j = i + 2
inside_quote = ''
while j < len(original) and original[j] != quote_char:
inside_quote += original[j]
j += 1
if j < len(original) and original[j] == quote_char:
has_flag_chars_inside = bool(re.match(r'^-+[a-zA-Z0-9$`]', inside_quote))
char_after = original[j + 1] if j + 1 < len(original) else ''
has_flag_continuing = (
bool(re.match(r'^-+$', inside_quote)) and
bool(char_after) and
bool(re.match(r'[a-zA-Z0-9\\${`\-]', char_after))
)
if has_flag_chars_inside or has_flag_continuing:
return _ask('Command contains quoted characters in flag names')
# Look for whitespace followed by dash with quotes mixed in
if c and c in ' \t\n' and next_c == '-':
j = i + 1
flag_content = ''
while j < len(original):
fc = original[j]
if fc in ' \t\n\r=':
break
if fc in "'\"" and base == 'cut' and flag_content == '-d':
break
flag_content += fc
j += 1
if '"' in flag_content or "'" in flag_content:
return _ask('Command contains quoted characters in flag names')
return _passthrough()
def validate_shell_metacharacters(ctx: ValidationContext) -> SecurityResult:
"""Detect shell metacharacters in quoted arguments."""
unquoted = ctx.unquoted_content
if re.search(r'(?:^|\s)["\'][^"\']*[;&][^"\']*["\'](?:\s|$)', unquoted):
return _ask('Command contains shell metacharacters (;, |, or &) in arguments')
# Glob patterns with metacharacters
for pattern in [
r'-name\s+["\'][^"\']*[;|&][^"\']*["\']',
r'-path\s+["\'][^"\']*[;|&][^"\']*["\']',
r'-iname\s+["\'][^"\']*[;|&][^"\']*["\']',
]:
if re.search(pattern, unquoted):
return _ask('Command contains shell metacharacters (;, |, or &) in arguments')
return _passthrough()
def validate_dangerous_variables(ctx: ValidationContext) -> SecurityResult:
"""Detect variables in dangerous contexts (redirections or pipes)."""
fully_unquoted = ctx.fully_unquoted_content
if (re.search(r'[<>|]\s*\$[A-Za-z_]', fully_unquoted) or
re.search(r'\$[A-Za-z_][A-Za-z0-9_]*\s*[|<>]', fully_unquoted)):
return _ask('Command contains variables in dangerous contexts (redirections or pipes)')
return _passthrough()
def validate_dangerous_patterns(ctx: ValidationContext) -> SecurityResult:
"""Detect backticks, $(), ${}, and other command substitution patterns."""
unquoted = ctx.unquoted_content
# Check for unescaped backticks
if has_unescaped_char(unquoted, '`'):
return _ask('Command contains backticks (`) for command substitution')
# Other command substitution patterns
for pattern, desc in COMMAND_SUBSTITUTION_PATTERNS:
if pattern.search(unquoted):
return _ask(f'Command contains {desc}')
return _passthrough()
def validate_redirections(ctx: ValidationContext) -> SecurityResult:
"""Detect input/output redirection."""
fully_unquoted = ctx.fully_unquoted_content
if '<' in fully_unquoted:
return _ask('Command contains input redirection (<) which could read sensitive files')
if '>' in fully_unquoted:
return _ask('Command contains output redirection (>) which could write to arbitrary files')
return _passthrough()
def validate_newlines(ctx: ValidationContext) -> SecurityResult:
"""Detect newlines that could separate multiple commands."""
fully_unquoted = ctx.fully_unquoted_pre_strip
if not re.search(r'[\n\r]', fully_unquoted):
return _passthrough()
# Check for newline followed by non-whitespace (potential second command)
# Allow backslash-newline line continuations at word boundaries
if re.search(r'(?<![\s]\\)[\n\r]\s*\S', fully_unquoted):
return _ask('Command contains newlines that could separate multiple commands')
return _passthrough()
def validate_carriage_return(ctx: ValidationContext) -> SecurityResult:
"""
Detect carriage returns that cause parser differentials.
CR is a misparsing concern because shell-quote treats it as a word
separator but bash does not.
"""
original = ctx.original_command
if '\r' not in original:
return _passthrough()
# Check if CR appears outside double quotes
in_single_quote = False
in_double_quote = False
escaped = False
for c in original:
if escaped:
escaped = False
continue
if c == '\\' and not in_single_quote:
escaped = True
continue
if c == "'" and not in_double_quote:
in_single_quote = not in_single_quote
continue
if c == '"' and not in_single_quote:
in_double_quote = not in_double_quote
continue
if c == '\r' and not in_double_quote:
return _ask(
'Command contains carriage return (\\r) which may cause parser differentials',
misparsing=True,
)
return _passthrough()
def validate_ifs_injection(ctx: ValidationContext) -> SecurityResult:
"""Detect IFS variable usage which could bypass security validation."""
if re.search(r'\$IFS|\$\{[^}]*IFS', ctx.original_command):
return _ask('Command contains IFS variable usage which could bypass security validation')
return _passthrough()
def validate_proc_environ_access(ctx: ValidationContext) -> SecurityResult:
"""Detect access to /proc/*/environ which could expose sensitive env vars."""
if re.search(r'/proc/.*/environ', ctx.original_command):
return _ask('Command accesses /proc/*/environ which could expose sensitive environment variables')
return _passthrough()
def validate_backslash_escaped_whitespace(ctx: ValidationContext) -> SecurityResult:
"""Detect backslash-escaped whitespace that could alter command parsing."""
if _has_backslash_escaped_whitespace(ctx.original_command):
return _ask(
'Command contains backslash-escaped whitespace that could alter command parsing',
misparsing=True,
)
return _passthrough()
def _has_backslash_escaped_whitespace(command: str) -> bool:
"""Check if command has backslash-escaped whitespace outside quotes."""
in_single_quote = False
in_double_quote = False
i = 0
while i < len(command):
c = command[i]
if c == '\\' and not in_single_quote:
if i + 1 < len(command):
next_c = command[i + 1]
if not in_double_quote and next_c in ' \t':
return True
i += 2
continue
if c == "'" and not in_double_quote:
in_single_quote = not in_single_quote
elif c == '"' and not in_single_quote:
in_double_quote = not in_double_quote
i += 1
return False
SHELL_OPERATORS = frozenset(';|&<>')
def validate_backslash_escaped_operators(ctx: ValidationContext) -> SecurityResult:
"""
Detect backslash before shell operators which can hide command structure.
E.g., `cat safe.txt \\; echo ~/.ssh/id_rsa`
"""
if _has_backslash_escaped_operator(ctx.original_command):
return _ask(
'Command contains a backslash before a shell operator (;, |, &, <, >) '
'which can hide command structure',
misparsing=True,
)
return _passthrough()
def _has_backslash_escaped_operator(command: str) -> bool:
"""Check if command has backslash-escaped shell operator outside quotes."""
in_single_quote = False
in_double_quote = False
i = 0
while i < len(command):
c = command[i]
if c == '\\' and not in_single_quote:
if not in_double_quote and i + 1 < len(command):
next_c = command[i + 1]
if next_c in SHELL_OPERATORS:
return True
i += 2
continue
if c == "'" and not in_double_quote:
in_single_quote = not in_single_quote
elif c == '"' and not in_single_quote:
in_double_quote = not in_double_quote
i += 1
return False
def validate_brace_expansion(ctx: ValidationContext) -> SecurityResult:
"""
Detect unquoted brace expansion syntax ({a,b} or {1..5}).
Brace expansion can alter command parsing and inject arguments.
"""
content = ctx.fully_unquoted_pre_strip
# Count unescaped braces
open_braces = 0
close_braces = 0
for i, c in enumerate(content):
if c == '{' and not _is_escaped_at_position(content, i):
open_braces += 1
elif c == '}' and not _is_escaped_at_position(content, i):
close_braces += 1
# Excess closing braces indicate obfuscation
if open_braces > 0 and close_braces > open_braces:
return _ask('Command has excess closing braces (possible brace expansion obfuscation)')
# Check for quoted brace inside brace context
if open_braces > 0 and re.search(r"""['"][{}]['"]""", ctx.original_command):
return _ask('Command contains quoted brace character inside brace context')
# Scan for actual brace expansion patterns ({a,b} or {a..b})
i = 0
while i < len(content):
if content[i] != '{' or _is_escaped_at_position(content, i):
i += 1
continue
# Find matching closing brace with nesting
depth = 1
matching_close = -1
j = i + 1
while j < len(content):
if content[j] == '{' and not _is_escaped_at_position(content, j):
depth += 1
elif content[j] == '}' and not _is_escaped_at_position(content, j):
depth -= 1
if depth == 0:
matching_close = j
break
j += 1
if matching_close == -1:
i += 1
continue
# Check for comma or .. at outermost level
inner_depth = 0
for k in range(i + 1, matching_close):
ch = content[k]
if ch == '{' and not _is_escaped_at_position(content, k):
inner_depth += 1
elif ch == '}' and not _is_escaped_at_position(content, k):
inner_depth -= 1
elif inner_depth == 0:
if ch == ',' or (ch == '.' and k + 1 < matching_close and content[k + 1] == '.'):
return _ask('Command contains brace expansion that could alter command parsing')
i += 1
return _passthrough()
def _is_escaped_at_position(content: str, pos: int) -> bool:
"""Check if character at position is escaped by counting preceding backslashes."""
count = 0
i = pos - 1
while i >= 0 and content[i] == '\\':
count += 1
i -= 1
return count % 2 == 1
def validate_unicode_whitespace(ctx: ValidationContext) -> SecurityResult:
"""Detect Unicode whitespace that could cause parser differentials."""
if UNICODE_WS_RE.search(ctx.original_command):
return _ask('Command contains Unicode whitespace characters that could cause parsing inconsistencies')
return _passthrough()
def validate_mid_word_hash(ctx: ValidationContext) -> SecurityResult:
"""
Detect mid-word # which is parsed differently by different shell parsers.
In bash, mid-word # is literal; in some parsers it starts a comment.
"""
text = ctx.unquoted_keep_quote_chars
# Match # preceded by non-whitespace, excluding ${#
if re.search(r'\S(?<!\$\{)#', text):
return _ask('Command contains mid-word # which may be parsed differently by different tools')
return _passthrough()
def validate_comment_quote_desync(ctx: ValidationContext) -> SecurityResult:
"""
Detect quote characters inside # comments that could desync quote trackers.
A ' or " after an unquoted # could confuse downstream processing.
"""
original = ctx.original_command
in_single_quote = False
in_double_quote = False
escaped = False
for i, char in enumerate(original):
if escaped:
escaped = False
continue
if in_single_quote:
if char == "'":
in_single_quote = False
continue
if char == '\\':
escaped = True
continue
if in_double_quote:
if char == '"':
in_double_quote = False
continue
if char == "'":
in_single_quote = True
continue
if char == '"':
in_double_quote = True
continue
if char == '#':
line_end = original.find('\n', i)
comment_text = original[i + 1: line_end if line_end != -1 else len(original)]
if re.search(r"""['"]""", comment_text):
return _ask(
'Command contains quote characters inside a # comment '
'which can desync quote tracking',
misparsing=True,
)
if line_end == -1:
break
# Skip to end of line
continue
return _passthrough()
def validate_quoted_newline(ctx: ValidationContext) -> SecurityResult:
"""
Detect newlines inside quoted strings where the next line starts with #.
This can hide arguments from line-based permission checks.
"""
original = ctx.original_command
if '\n' not in original or '#' not in original:
return _passthrough()
in_single_quote = False
in_double_quote = False
escaped = False
for i, char in enumerate(original):
if escaped:
escaped = False
continue
if char == '\\' and not in_single_quote:
escaped = True
continue
if char == "'" and not in_double_quote:
in_single_quote = not in_single_quote
continue
if char == '"' and not in_single_quote:
in_double_quote = not in_double_quote
continue
if char == '\n' and (in_single_quote or in_double_quote):
line_start = i + 1
next_newline = original.find('\n', line_start)
line_end = next_newline if next_newline != -1 else len(original)
next_line = original[line_start:line_end]
if next_line.strip().startswith('#'):
return _ask(
'Command contains a quoted newline followed by a #-prefixed line, '
'which can hide arguments from permission checks',
misparsing=True,
)
return _passthrough()
def validate_zsh_dangerous_commands(ctx: ValidationContext) -> SecurityResult:
"""Detect Zsh-specific dangerous commands."""
original = ctx.original_command
trimmed = original.strip()
tokens = trimmed.split()
base_cmd = ''
for token in tokens:
if re.match(r'^[A-Za-z_]\w*=', token):
continue
if token in ZSH_PRECOMMAND_MODIFIERS:
continue
base_cmd = token
break
if base_cmd in ZSH_DANGEROUS_COMMANDS:
return _ask(f"Command uses Zsh-specific '{base_cmd}' which can bypass security checks")
# Check for fc -e (arbitrary command execution via editor)
if base_cmd == 'fc' and re.search(r'\s-\S*e', trimmed):
return _ask("Command uses 'fc -e' which can execute arbitrary commands via editor")
return _passthrough()
# ---------------------------------------------------------------------------
# Main security check entry point
# ---------------------------------------------------------------------------
# Validators that don't indicate misparsing concerns
_NON_MISPARSING_VALIDATORS = frozenset({
'validate_newlines',
'validate_redirections',
})
def bash_command_is_safe(command: str) -> SecurityResult:
"""
Main entry point: check if a bash command is safe to execute.
Returns a SecurityResult with behavior:
- ALLOW: Command is safe, auto-approve
- ASK: Command needs user confirmation
- DENY: Command is blocked
- PASSTHROUGH: All checks passed, proceed with normal permission flow
"""
# Block control characters first
result = validate_control_characters(ValidationContext(
original_command=command, base_command='',
unquoted_content='', fully_unquoted_content='',
fully_unquoted_pre_strip='', unquoted_keep_quote_chars='',
))
if result.behavior != SecurityBehavior.PASSTHROUGH:
return result
# Build validation context
base_command = command.split()[0] if command.strip() else ''
with_double_quotes, fully_unquoted, unquoted_keep_quote_chars = extract_quoted_content(command)
ctx = ValidationContext(
original_command=command,
base_command=base_command,
unquoted_content=with_double_quotes,
fully_unquoted_content=strip_safe_redirections(fully_unquoted),
fully_unquoted_pre_strip=fully_unquoted,
unquoted_keep_quote_chars=unquoted_keep_quote_chars,
)
# Early validators (can allow or block)
early_validators = [
validate_empty,
validate_incomplete_commands,
validate_git_commit,
]
for validator in early_validators:
result = validator(ctx)
if result.behavior == SecurityBehavior.ALLOW:
return _passthrough(result.message)
if result.behavior != SecurityBehavior.PASSTHROUGH:
return result
# Main validators
validators: list[tuple[str, Callable[[ValidationContext], SecurityResult]]] = [
('validate_jq_command', validate_jq_command),
('validate_obfuscated_flags', validate_obfuscated_flags),
('validate_shell_metacharacters', validate_shell_metacharacters),
('validate_dangerous_variables', validate_dangerous_variables),
('validate_comment_quote_desync', validate_comment_quote_desync),
('validate_quoted_newline', validate_quoted_newline),
('validate_carriage_return', validate_carriage_return),
('validate_newlines', validate_newlines),
('validate_ifs_injection', validate_ifs_injection),
('validate_proc_environ_access', validate_proc_environ_access),
('validate_dangerous_patterns', validate_dangerous_patterns),
('validate_redirections', validate_redirections),
('validate_backslash_escaped_whitespace', validate_backslash_escaped_whitespace),
('validate_backslash_escaped_operators', validate_backslash_escaped_operators),
('validate_unicode_whitespace', validate_unicode_whitespace),
('validate_mid_word_hash', validate_mid_word_hash),
('validate_brace_expansion', validate_brace_expansion),
('validate_zsh_dangerous_commands', validate_zsh_dangerous_commands),
]
# Defer non-misparsing results to let misparsing validators run
deferred_non_misparsing: Optional[SecurityResult] = None
for name, validator in validators:
result = validator(ctx)
if result.behavior == SecurityBehavior.ASK:
if name in _NON_MISPARSING_VALIDATORS:
if deferred_non_misparsing is None:
deferred_non_misparsing = result
continue
return SecurityResult(
SecurityBehavior.ASK, result.message, is_misparsing=True,
)
if deferred_non_misparsing is not None:
return deferred_non_misparsing
return _passthrough('Command passed all security checks')
# ---------------------------------------------------------------------------
# Destructive command warning (informational, not blocking)
# ---------------------------------------------------------------------------
def get_destructive_command_warning(command: str) -> Optional[str]:
"""
Check if a command matches known destructive patterns.
Returns a warning string or None.
"""
for pattern, warning in DESTRUCTIVE_PATTERNS:
if pattern.search(command):
return warning
return None
# ---------------------------------------------------------------------------
# Enhanced shell security check (replaces basic _ensure_shell_allowed)
# ---------------------------------------------------------------------------
def check_shell_security(
command: str,
*,
allow_shell: bool = True,
allow_destructive: bool = False,
) -> tuple[bool, str]:
"""
Comprehensive shell security check. Returns (allowed, message).
This is the main integration point for the agent's tool execution.
It replaces the basic destructive pattern matching with full security validation.
Args:
command: The shell command to validate
allow_shell: Whether shell commands are enabled at all
allow_destructive: Whether destructive commands are allowed
Returns:
(True, '') if command is allowed
(False, reason) if command should be blocked
"""
if not allow_shell:
return (False, 'Shell commands are disabled. Re-run with --allow-shell to enable bash.')
# Run full security validation
result = bash_command_is_safe(command)
if result.behavior == SecurityBehavior.DENY:
return (False, result.message)
if result.behavior == SecurityBehavior.ASK:
# For misparsing concerns, always block
if result.is_misparsing:
return (False, f'Security check: {result.message}')
# Check destructive patterns if not allowed
if not allow_destructive:
warning = get_destructive_command_warning(command)
if warning:
return (False, f'Potentially destructive command blocked: {warning}. '
'Re-run with --unsafe to allow it.')
return (True, '')