ripperdoc 0.2.6__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ripperdoc/__init__.py +3 -0
- ripperdoc/__main__.py +20 -0
- ripperdoc/cli/__init__.py +1 -0
- ripperdoc/cli/cli.py +405 -0
- ripperdoc/cli/commands/__init__.py +82 -0
- ripperdoc/cli/commands/agents_cmd.py +263 -0
- ripperdoc/cli/commands/base.py +19 -0
- ripperdoc/cli/commands/clear_cmd.py +18 -0
- ripperdoc/cli/commands/compact_cmd.py +23 -0
- ripperdoc/cli/commands/config_cmd.py +31 -0
- ripperdoc/cli/commands/context_cmd.py +144 -0
- ripperdoc/cli/commands/cost_cmd.py +82 -0
- ripperdoc/cli/commands/doctor_cmd.py +221 -0
- ripperdoc/cli/commands/exit_cmd.py +19 -0
- ripperdoc/cli/commands/help_cmd.py +20 -0
- ripperdoc/cli/commands/mcp_cmd.py +70 -0
- ripperdoc/cli/commands/memory_cmd.py +202 -0
- ripperdoc/cli/commands/models_cmd.py +413 -0
- ripperdoc/cli/commands/permissions_cmd.py +302 -0
- ripperdoc/cli/commands/resume_cmd.py +98 -0
- ripperdoc/cli/commands/status_cmd.py +167 -0
- ripperdoc/cli/commands/tasks_cmd.py +278 -0
- ripperdoc/cli/commands/todos_cmd.py +69 -0
- ripperdoc/cli/commands/tools_cmd.py +19 -0
- ripperdoc/cli/ui/__init__.py +1 -0
- ripperdoc/cli/ui/context_display.py +298 -0
- ripperdoc/cli/ui/helpers.py +22 -0
- ripperdoc/cli/ui/rich_ui.py +1557 -0
- ripperdoc/cli/ui/spinner.py +49 -0
- ripperdoc/cli/ui/thinking_spinner.py +128 -0
- ripperdoc/cli/ui/tool_renderers.py +298 -0
- ripperdoc/core/__init__.py +1 -0
- ripperdoc/core/agents.py +486 -0
- ripperdoc/core/commands.py +33 -0
- ripperdoc/core/config.py +559 -0
- ripperdoc/core/default_tools.py +88 -0
- ripperdoc/core/permissions.py +252 -0
- ripperdoc/core/providers/__init__.py +47 -0
- ripperdoc/core/providers/anthropic.py +250 -0
- ripperdoc/core/providers/base.py +265 -0
- ripperdoc/core/providers/gemini.py +615 -0
- ripperdoc/core/providers/openai.py +487 -0
- ripperdoc/core/query.py +1058 -0
- ripperdoc/core/query_utils.py +622 -0
- ripperdoc/core/skills.py +295 -0
- ripperdoc/core/system_prompt.py +431 -0
- ripperdoc/core/tool.py +240 -0
- ripperdoc/sdk/__init__.py +9 -0
- ripperdoc/sdk/client.py +333 -0
- ripperdoc/tools/__init__.py +1 -0
- ripperdoc/tools/ask_user_question_tool.py +431 -0
- ripperdoc/tools/background_shell.py +389 -0
- ripperdoc/tools/bash_output_tool.py +98 -0
- ripperdoc/tools/bash_tool.py +1016 -0
- ripperdoc/tools/dynamic_mcp_tool.py +428 -0
- ripperdoc/tools/enter_plan_mode_tool.py +226 -0
- ripperdoc/tools/exit_plan_mode_tool.py +153 -0
- ripperdoc/tools/file_edit_tool.py +346 -0
- ripperdoc/tools/file_read_tool.py +203 -0
- ripperdoc/tools/file_write_tool.py +205 -0
- ripperdoc/tools/glob_tool.py +179 -0
- ripperdoc/tools/grep_tool.py +370 -0
- ripperdoc/tools/kill_bash_tool.py +136 -0
- ripperdoc/tools/ls_tool.py +471 -0
- ripperdoc/tools/mcp_tools.py +591 -0
- ripperdoc/tools/multi_edit_tool.py +456 -0
- ripperdoc/tools/notebook_edit_tool.py +386 -0
- ripperdoc/tools/skill_tool.py +205 -0
- ripperdoc/tools/task_tool.py +379 -0
- ripperdoc/tools/todo_tool.py +494 -0
- ripperdoc/tools/tool_search_tool.py +380 -0
- ripperdoc/utils/__init__.py +1 -0
- ripperdoc/utils/bash_constants.py +51 -0
- ripperdoc/utils/bash_output_utils.py +43 -0
- ripperdoc/utils/coerce.py +34 -0
- ripperdoc/utils/context_length_errors.py +252 -0
- ripperdoc/utils/exit_code_handlers.py +241 -0
- ripperdoc/utils/file_watch.py +135 -0
- ripperdoc/utils/git_utils.py +274 -0
- ripperdoc/utils/json_utils.py +27 -0
- ripperdoc/utils/log.py +176 -0
- ripperdoc/utils/mcp.py +560 -0
- ripperdoc/utils/memory.py +253 -0
- ripperdoc/utils/message_compaction.py +676 -0
- ripperdoc/utils/messages.py +519 -0
- ripperdoc/utils/output_utils.py +258 -0
- ripperdoc/utils/path_ignore.py +677 -0
- ripperdoc/utils/path_utils.py +46 -0
- ripperdoc/utils/permissions/__init__.py +27 -0
- ripperdoc/utils/permissions/path_validation_utils.py +174 -0
- ripperdoc/utils/permissions/shell_command_validation.py +552 -0
- ripperdoc/utils/permissions/tool_permission_utils.py +279 -0
- ripperdoc/utils/prompt.py +17 -0
- ripperdoc/utils/safe_get_cwd.py +31 -0
- ripperdoc/utils/sandbox_utils.py +38 -0
- ripperdoc/utils/session_history.py +260 -0
- ripperdoc/utils/session_usage.py +117 -0
- ripperdoc/utils/shell_token_utils.py +95 -0
- ripperdoc/utils/shell_utils.py +159 -0
- ripperdoc/utils/todo.py +203 -0
- ripperdoc/utils/token_estimation.py +34 -0
- ripperdoc-0.2.6.dist-info/METADATA +193 -0
- ripperdoc-0.2.6.dist-info/RECORD +107 -0
- ripperdoc-0.2.6.dist-info/WHEEL +5 -0
- ripperdoc-0.2.6.dist-info/entry_points.txt +3 -0
- ripperdoc-0.2.6.dist-info/licenses/LICENSE +53 -0
- ripperdoc-0.2.6.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
"""Shell command validation with comprehensive security checks.
|
|
2
|
+
|
|
3
|
+
This module implements security checks for shell commands to detect
|
|
4
|
+
potentially dangerous constructs before execution.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import List, Optional, Tuple
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class ValidationResult:
|
|
16
|
+
"""Result of shell command validation."""
|
|
17
|
+
|
|
18
|
+
behavior: str # 'passthrough' | 'ask' | 'allow' | 'deny'
|
|
19
|
+
message: str
|
|
20
|
+
rule_suggestions: Optional[List[str]] = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _strip_single_quotes(shell_command: str, first_token: str) -> str:
|
|
24
|
+
"""Strip content inside single quotes, handling escapes properly.
|
|
25
|
+
|
|
26
|
+
Single-quoted content in shell is literal and cannot contain command
|
|
27
|
+
substitution, so we can safely ignore it for security analysis.
|
|
28
|
+
"""
|
|
29
|
+
in_single_quote_mode = False
|
|
30
|
+
next_char_is_backslash_escaped = False
|
|
31
|
+
command_without_single_quotes = ""
|
|
32
|
+
|
|
33
|
+
for i, current_char in enumerate(shell_command):
|
|
34
|
+
if next_char_is_backslash_escaped:
|
|
35
|
+
next_char_is_backslash_escaped = False
|
|
36
|
+
if not in_single_quote_mode:
|
|
37
|
+
command_without_single_quotes += current_char
|
|
38
|
+
continue
|
|
39
|
+
|
|
40
|
+
if current_char == "\\":
|
|
41
|
+
next_char_is_backslash_escaped = True
|
|
42
|
+
if not in_single_quote_mode:
|
|
43
|
+
command_without_single_quotes += current_char
|
|
44
|
+
continue
|
|
45
|
+
|
|
46
|
+
if current_char == "'" and not next_char_is_backslash_escaped:
|
|
47
|
+
in_single_quote_mode = not in_single_quote_mode
|
|
48
|
+
continue
|
|
49
|
+
|
|
50
|
+
# Special handling for jq double-quoted strings
|
|
51
|
+
if (
|
|
52
|
+
first_token == "jq"
|
|
53
|
+
and current_char == '"'
|
|
54
|
+
and not next_char_is_backslash_escaped
|
|
55
|
+
and not in_single_quote_mode
|
|
56
|
+
):
|
|
57
|
+
# Scan to find the end of the double-quoted string
|
|
58
|
+
quoted_string = ""
|
|
59
|
+
scan_position = i + 1
|
|
60
|
+
|
|
61
|
+
while scan_position < len(shell_command) and shell_command[scan_position] != '"':
|
|
62
|
+
if (
|
|
63
|
+
shell_command[scan_position] == "\\"
|
|
64
|
+
and scan_position + 1 < len(shell_command)
|
|
65
|
+
):
|
|
66
|
+
scan_position += 2
|
|
67
|
+
continue
|
|
68
|
+
quoted_string += shell_command[scan_position]
|
|
69
|
+
scan_position += 1
|
|
70
|
+
|
|
71
|
+
# If the quoted string contains command substitution, keep it for analysis
|
|
72
|
+
if "$(" in quoted_string or "`" in quoted_string:
|
|
73
|
+
command_without_single_quotes += current_char
|
|
74
|
+
continue
|
|
75
|
+
|
|
76
|
+
# Skip the entire quoted string
|
|
77
|
+
# Note: We can't modify i in Python, so we'll need a different approach
|
|
78
|
+
# For now, just add the character if not in single quote mode
|
|
79
|
+
|
|
80
|
+
if not in_single_quote_mode:
|
|
81
|
+
command_without_single_quotes += current_char
|
|
82
|
+
|
|
83
|
+
return command_without_single_quotes
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _sanitize_safe_redirections(command: str) -> str:
|
|
87
|
+
"""Remove safe redirection patterns that don't need security checks."""
|
|
88
|
+
# Remove stderr to stdout redirection (2>&1)
|
|
89
|
+
sanitized = re.sub(r"\s+2\s*>&\s*1(?=\s|$)", "", command)
|
|
90
|
+
# Remove redirections to /dev/null
|
|
91
|
+
sanitized = re.sub(r"[012]?\s*>\s*/dev/null", "", sanitized)
|
|
92
|
+
# Remove input from /dev/null
|
|
93
|
+
sanitized = re.sub(r"\s*<\s*/dev/null", "", sanitized)
|
|
94
|
+
return sanitized
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# Dangerous patterns to check in sanitized commands
|
|
98
|
+
_DANGEROUS_PATTERNS: List[Tuple[re.Pattern[str], str]] = [
|
|
99
|
+
# Newlines can break out of quotes
|
|
100
|
+
(re.compile(r"[\n\r]"), "Command contains newlines which could break out of quotes"),
|
|
101
|
+
# Process substitution
|
|
102
|
+
(re.compile(r"<\("), "Command contains process substitution <()"),
|
|
103
|
+
(re.compile(r">\("), "Command contains process substitution >()"),
|
|
104
|
+
# Command substitution
|
|
105
|
+
(re.compile(r"[`‵]"), "Command contains backticks (`) for command substitution"),
|
|
106
|
+
(re.compile(r"\$\("), "Command contains $() command substitution"),
|
|
107
|
+
# Parameter substitution
|
|
108
|
+
(re.compile(r"\$\{"), "Command contains ${} parameter substitution"),
|
|
109
|
+
# Input/output redirection
|
|
110
|
+
(re.compile(r"<(?!\()"), "Command contains input redirection (<) which could read sensitive files"),
|
|
111
|
+
(re.compile(r">(?!\()"), "Command contains output redirection (>) which could write to arbitrary files"),
|
|
112
|
+
# Zsh-specific patterns
|
|
113
|
+
(re.compile(r"~\["), "Command contains Zsh-style parameter expansion"),
|
|
114
|
+
(re.compile(r"\(e:"), "Command contains Zsh-style glob qualifiers"),
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
# Patterns that indicate dangerous metacharacters in find/grep arguments
|
|
118
|
+
_DANGEROUS_METACHARACTER_PATTERNS: List[re.Pattern[str]] = [
|
|
119
|
+
re.compile(r'-name\s+["\']?[^"\']*[;|&][^"\']*["\']?'),
|
|
120
|
+
re.compile(r'-path\s+["\']?[^"\']*[;|&][^"\']*["\']?'),
|
|
121
|
+
re.compile(r'-iname\s+["\']?[^"\']*[;|&][^"\']*["\']?'),
|
|
122
|
+
re.compile(r'-regex\s+["\']?[^"\']*[;|&][^"\']*["\']?'),
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
# =============================================================================
|
|
126
|
+
# Destructive Command Detection (Cross-platform)
|
|
127
|
+
# =============================================================================
|
|
128
|
+
# These patterns detect commands that can cause irreversible data loss.
|
|
129
|
+
# Inspired by the Gemini incident where `cmd /c "rmdir /s /q ..."` deleted C:\
|
|
130
|
+
|
|
131
|
+
# Windows destructive commands
|
|
132
|
+
_WINDOWS_DESTRUCTIVE_PATTERNS: List[Tuple[re.Pattern[str], str]] = [
|
|
133
|
+
# rmdir /s - Recursive directory deletion (Windows)
|
|
134
|
+
(
|
|
135
|
+
re.compile(r'\brmdir\s+.*(/s|/S)', re.IGNORECASE),
|
|
136
|
+
"Command contains 'rmdir /s' which recursively deletes directories"
|
|
137
|
+
),
|
|
138
|
+
# del /s or del /q - Recursive or quiet file deletion (Windows)
|
|
139
|
+
(
|
|
140
|
+
re.compile(r'\bdel\s+.*(/s|/S|/q|/Q)', re.IGNORECASE),
|
|
141
|
+
"Command contains 'del' with dangerous flags (/s or /q)"
|
|
142
|
+
),
|
|
143
|
+
# rd /s - Alias for rmdir /s (Windows)
|
|
144
|
+
(
|
|
145
|
+
re.compile(r'\brd\s+.*(/s|/S)', re.IGNORECASE),
|
|
146
|
+
"Command contains 'rd /s' which recursively deletes directories"
|
|
147
|
+
),
|
|
148
|
+
# format command (Windows)
|
|
149
|
+
(
|
|
150
|
+
re.compile(r'\bformat\s+[a-zA-Z]:', re.IGNORECASE),
|
|
151
|
+
"Command contains 'format' which erases entire drives"
|
|
152
|
+
),
|
|
153
|
+
# cmd /c with destructive subcommand
|
|
154
|
+
(
|
|
155
|
+
re.compile(r'\bcmd\s+/[cC]\s+.*\b(rmdir|rd|del|format)\b', re.IGNORECASE),
|
|
156
|
+
"Command uses 'cmd /c' to execute a destructive subcommand"
|
|
157
|
+
),
|
|
158
|
+
# PowerShell Remove-Item -Recurse
|
|
159
|
+
(
|
|
160
|
+
re.compile(r'\b(Remove-Item|rm|ri|del)\s+.*-Recurse', re.IGNORECASE),
|
|
161
|
+
"Command contains 'Remove-Item -Recurse' which recursively deletes items"
|
|
162
|
+
),
|
|
163
|
+
# PowerShell with -Force flag on destructive commands
|
|
164
|
+
(
|
|
165
|
+
re.compile(r'\b(Remove-Item|rm|ri|del)\s+.*-Force', re.IGNORECASE),
|
|
166
|
+
"Command contains destructive command with -Force flag"
|
|
167
|
+
),
|
|
168
|
+
]
|
|
169
|
+
|
|
170
|
+
# Unix/Linux destructive commands
|
|
171
|
+
_UNIX_DESTRUCTIVE_PATTERNS: List[Tuple[re.Pattern[str], str]] = [
|
|
172
|
+
# rm -rf or rm -r (recursive deletion) - must be at word boundary and followed by space/path
|
|
173
|
+
(
|
|
174
|
+
re.compile(r'(?<!["\'])\brm\s+(-[a-zA-Z]*r[a-zA-Z]*\s+|\s*-[a-zA-Z]*r[a-zA-Z]*$)'),
|
|
175
|
+
"Command contains 'rm -r' which recursively deletes files and directories"
|
|
176
|
+
),
|
|
177
|
+
# rm with force flag on system paths
|
|
178
|
+
(
|
|
179
|
+
re.compile(r'(?<!["\'])\brm\s+-[a-zA-Z]*f[a-zA-Z]*\s+(/|~|/home|/usr|/var|/etc|/root|\$HOME)'),
|
|
180
|
+
"Command contains 'rm -f' targeting a critical system path"
|
|
181
|
+
),
|
|
182
|
+
# dd command (can overwrite disks)
|
|
183
|
+
(
|
|
184
|
+
re.compile(r'\bdd\s+.*of=/dev/'),
|
|
185
|
+
"Command contains 'dd' writing to a device file"
|
|
186
|
+
),
|
|
187
|
+
# mkfs (creates filesystem, destroys data)
|
|
188
|
+
(
|
|
189
|
+
re.compile(r'\bmkfs\b'),
|
|
190
|
+
"Command contains 'mkfs' which formats storage devices"
|
|
191
|
+
),
|
|
192
|
+
# shred (secure deletion)
|
|
193
|
+
(
|
|
194
|
+
re.compile(r'\bshred\s+'),
|
|
195
|
+
"Command contains 'shred' which irreversibly destroys file data"
|
|
196
|
+
),
|
|
197
|
+
# chmod 777 on sensitive paths
|
|
198
|
+
(
|
|
199
|
+
re.compile(r'\bchmod\s+777\s+(/|/etc|/usr|/var|/home)'),
|
|
200
|
+
"Command contains 'chmod 777' on a sensitive system path"
|
|
201
|
+
),
|
|
202
|
+
# chown on system paths
|
|
203
|
+
(
|
|
204
|
+
re.compile(r'\bchown\s+.*\s+(/etc|/usr|/var|/bin|/sbin)'),
|
|
205
|
+
"Command contains 'chown' on a critical system path"
|
|
206
|
+
),
|
|
207
|
+
]
|
|
208
|
+
|
|
209
|
+
# Path patterns that should trigger extra scrutiny
|
|
210
|
+
_CRITICAL_PATH_PATTERNS: List[re.Pattern[str]] = [
|
|
211
|
+
# Windows critical paths
|
|
212
|
+
re.compile(r'["\']?[A-Za-z]:\\(?:Windows|Program Files|Users)\\?["\']?', re.IGNORECASE),
|
|
213
|
+
re.compile(r'["\']?[A-Za-z]:\\["\']?(?:\s|$)', re.IGNORECASE), # Root of any drive
|
|
214
|
+
# Unix critical paths
|
|
215
|
+
re.compile(r'["\']?/(?:etc|usr|var|bin|sbin|lib|boot|root|home)["\']?(?:\s|$)'),
|
|
216
|
+
re.compile(r'["\']?/["\']?(?:\s|$)'), # Root directory
|
|
217
|
+
re.compile(r'["\']?~["\']?(?:\s|$)'), # Home directory
|
|
218
|
+
]
|
|
219
|
+
|
|
220
|
+
# Commands with nested/escaped quotes that might cause parsing issues
|
|
221
|
+
_NESTED_QUOTE_PATTERNS: List[Tuple[re.Pattern[str], str]] = [
|
|
222
|
+
# Windows cmd with escaped quotes inside
|
|
223
|
+
(
|
|
224
|
+
re.compile(r'\bcmd\s+/[cC]\s+"[^"]*\\"[^"]*"'),
|
|
225
|
+
"Command contains 'cmd /c' with nested escaped quotes which may cause unexpected parsing"
|
|
226
|
+
),
|
|
227
|
+
# PowerShell with complex quoting
|
|
228
|
+
(
|
|
229
|
+
re.compile(r'\bpowershell\s+.*-[Cc]ommand\s+["\'][^"\']*["\'][^"\']*["\']'),
|
|
230
|
+
"Command contains PowerShell with complex nested quotes"
|
|
231
|
+
),
|
|
232
|
+
]
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _check_destructive_commands(command: str) -> Optional[ValidationResult]:
|
|
236
|
+
"""Check for destructive commands that could cause irreversible data loss.
|
|
237
|
+
|
|
238
|
+
This function specifically addresses scenarios like the Gemini incident
|
|
239
|
+
where a command with improper quoting deleted an entire drive.
|
|
240
|
+
"""
|
|
241
|
+
# First, strip content inside quotes for Unix destructive pattern matching
|
|
242
|
+
# to avoid false positives like 'find . -name "*.py;rm -rf /"'
|
|
243
|
+
# But for Windows commands, we check the full command since quoting is different
|
|
244
|
+
|
|
245
|
+
# Check for nested quote issues first (like the Gemini incident)
|
|
246
|
+
for pattern, message in _NESTED_QUOTE_PATTERNS:
|
|
247
|
+
if pattern.search(command):
|
|
248
|
+
# If nested quotes AND targets critical path, deny
|
|
249
|
+
has_critical_path = any(p.search(command) for p in _CRITICAL_PATH_PATTERNS)
|
|
250
|
+
if has_critical_path:
|
|
251
|
+
return ValidationResult(
|
|
252
|
+
behavior="deny",
|
|
253
|
+
message=f"BLOCKED: {message} targeting a critical system path",
|
|
254
|
+
rule_suggestions=None,
|
|
255
|
+
)
|
|
256
|
+
return ValidationResult(
|
|
257
|
+
behavior="ask",
|
|
258
|
+
message=message,
|
|
259
|
+
rule_suggestions=None,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
# Check if command targets critical paths with any destructive operation
|
|
263
|
+
has_critical_path = any(p.search(command) for p in _CRITICAL_PATH_PATTERNS)
|
|
264
|
+
|
|
265
|
+
# Strip quoted content to avoid false positives like 'echo "rmdir /s /q folder"'
|
|
266
|
+
command_without_quotes = _strip_quoted_content_for_destructive_check(command)
|
|
267
|
+
|
|
268
|
+
# Check Windows destructive patterns on stripped command
|
|
269
|
+
for pattern, message in _WINDOWS_DESTRUCTIVE_PATTERNS:
|
|
270
|
+
if pattern.search(command_without_quotes):
|
|
271
|
+
if has_critical_path:
|
|
272
|
+
return ValidationResult(
|
|
273
|
+
behavior="deny",
|
|
274
|
+
message=f"BLOCKED: {message} targeting a critical system path",
|
|
275
|
+
rule_suggestions=None,
|
|
276
|
+
)
|
|
277
|
+
return ValidationResult(
|
|
278
|
+
behavior="ask",
|
|
279
|
+
message=message,
|
|
280
|
+
rule_suggestions=None,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
# Check Unix destructive patterns on the stripped command
|
|
284
|
+
for pattern, message in _UNIX_DESTRUCTIVE_PATTERNS:
|
|
285
|
+
if pattern.search(command_without_quotes):
|
|
286
|
+
# Re-check critical path on original command
|
|
287
|
+
if has_critical_path:
|
|
288
|
+
return ValidationResult(
|
|
289
|
+
behavior="deny",
|
|
290
|
+
message=f"BLOCKED: {message} targeting a critical system path",
|
|
291
|
+
rule_suggestions=None,
|
|
292
|
+
)
|
|
293
|
+
return ValidationResult(
|
|
294
|
+
behavior="ask",
|
|
295
|
+
message=message,
|
|
296
|
+
rule_suggestions=None,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
# Special check for the exact Gemini incident pattern
|
|
300
|
+
# cmd /c "rmdir /s /q \"path with spaces\""
|
|
301
|
+
if re.search(r'\bcmd\s+/[cC]\s+"[^"]*\\[""][^"]*"', command):
|
|
302
|
+
if has_critical_path:
|
|
303
|
+
return ValidationResult(
|
|
304
|
+
behavior="deny",
|
|
305
|
+
message="BLOCKED: Command contains 'cmd /c' with escaped quotes - "
|
|
306
|
+
"this pattern has caused data loss incidents",
|
|
307
|
+
rule_suggestions=None,
|
|
308
|
+
)
|
|
309
|
+
return ValidationResult(
|
|
310
|
+
behavior="ask",
|
|
311
|
+
message="Command contains 'cmd /c' with escaped quotes inside double quotes - "
|
|
312
|
+
"this pattern has caused data loss incidents due to quote parsing issues",
|
|
313
|
+
rule_suggestions=None,
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
return None
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _strip_quoted_content_for_destructive_check(command: str) -> str:
|
|
320
|
+
"""Strip content inside quotes for destructive command checking.
|
|
321
|
+
|
|
322
|
+
This prevents false positives like 'find . -name "rm -rf /"' from
|
|
323
|
+
triggering the rm -rf detection.
|
|
324
|
+
"""
|
|
325
|
+
result = []
|
|
326
|
+
in_single_quote = False
|
|
327
|
+
in_double_quote = False
|
|
328
|
+
escaped = False
|
|
329
|
+
|
|
330
|
+
for char in command:
|
|
331
|
+
if escaped:
|
|
332
|
+
escaped = False
|
|
333
|
+
if not in_single_quote and not in_double_quote:
|
|
334
|
+
result.append(char)
|
|
335
|
+
continue
|
|
336
|
+
|
|
337
|
+
if char == '\\':
|
|
338
|
+
escaped = True
|
|
339
|
+
if not in_single_quote and not in_double_quote:
|
|
340
|
+
result.append(char)
|
|
341
|
+
continue
|
|
342
|
+
|
|
343
|
+
if char == "'" and not in_double_quote:
|
|
344
|
+
in_single_quote = not in_single_quote
|
|
345
|
+
continue
|
|
346
|
+
|
|
347
|
+
if char == '"' and not in_single_quote:
|
|
348
|
+
in_double_quote = not in_double_quote
|
|
349
|
+
continue
|
|
350
|
+
|
|
351
|
+
if not in_single_quote and not in_double_quote:
|
|
352
|
+
result.append(char)
|
|
353
|
+
|
|
354
|
+
return ''.join(result)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def validate_shell_command(shell_command: str) -> ValidationResult:
|
|
358
|
+
"""Validate a shell command for security risks.
|
|
359
|
+
|
|
360
|
+
This function checks for potentially dangerous shell constructs that could
|
|
361
|
+
be used for command injection or other security issues.
|
|
362
|
+
|
|
363
|
+
Args:
|
|
364
|
+
shell_command: The shell command to validate.
|
|
365
|
+
|
|
366
|
+
Returns:
|
|
367
|
+
ValidationResult with behavior indicating whether the command is safe.
|
|
368
|
+
"""
|
|
369
|
+
if not shell_command or not shell_command.strip():
|
|
370
|
+
return ValidationResult(
|
|
371
|
+
behavior="passthrough",
|
|
372
|
+
message="Empty command is safe",
|
|
373
|
+
rule_suggestions=None,
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
trimmed = shell_command.strip()
|
|
377
|
+
first_token = trimmed.split()[0] if trimmed.split() else ""
|
|
378
|
+
|
|
379
|
+
# FIRST: Check for destructive commands (highest priority)
|
|
380
|
+
# This catches dangerous patterns like the Gemini incident
|
|
381
|
+
destructive_result = _check_destructive_commands(trimmed)
|
|
382
|
+
if destructive_result:
|
|
383
|
+
return destructive_result
|
|
384
|
+
|
|
385
|
+
# Special handling for jq commands
|
|
386
|
+
if first_token == "jq":
|
|
387
|
+
# Check for system() function which can execute arbitrary commands
|
|
388
|
+
if re.search(r"\bsystem\s*\(", trimmed):
|
|
389
|
+
return ValidationResult(
|
|
390
|
+
behavior="ask",
|
|
391
|
+
message="jq command contains system() function which executes arbitrary commands",
|
|
392
|
+
rule_suggestions=None,
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
# Check if jq is reading from files (should only read from stdin)
|
|
396
|
+
jq_args = trimmed[3:].strip() if len(trimmed) > 3 else ""
|
|
397
|
+
if re.search(r'(?:^|\s)(?:[^\'"\s-][^\s]*\s+)?(?:/|~|\w+\.\w+)', jq_args):
|
|
398
|
+
if not re.match(r"^\.[^\s]+$", jq_args):
|
|
399
|
+
return ValidationResult(
|
|
400
|
+
behavior="ask",
|
|
401
|
+
message="jq command contains file arguments - jq should only read from stdin in read-only mode",
|
|
402
|
+
rule_suggestions=None,
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
# Allow git commit with single-quoted heredoc (common pattern for commit messages)
|
|
406
|
+
if re.search(r"git\s+commit\s+.*-m\s+\"?\$\(cat\s*<<'[^']+'[\s\S]*?\)\"?", trimmed):
|
|
407
|
+
return ValidationResult(
|
|
408
|
+
behavior="passthrough",
|
|
409
|
+
message="Git commit with single-quoted heredoc is allowed",
|
|
410
|
+
rule_suggestions=None,
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
# Check for heredoc with command substitution (dangerous)
|
|
414
|
+
if re.search(r"['\"]?\$\(cat\s*<<(?!')", trimmed):
|
|
415
|
+
return ValidationResult(
|
|
416
|
+
behavior="ask",
|
|
417
|
+
message="Command contains heredoc with command substitution",
|
|
418
|
+
rule_suggestions=None,
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
# Check for heredoc patterns that could run arbitrary content
|
|
422
|
+
if re.search(r"\b(cat|tee)\s+<<\s*['\"]?EOF", trimmed):
|
|
423
|
+
return ValidationResult(
|
|
424
|
+
behavior="ask",
|
|
425
|
+
message="Command contains heredoc which may run arbitrary content",
|
|
426
|
+
rule_suggestions=None,
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
# Strip single-quoted content for further analysis
|
|
430
|
+
sanitized = _strip_single_quotes(trimmed, first_token)
|
|
431
|
+
|
|
432
|
+
# Remove safe redirections
|
|
433
|
+
sanitized = _sanitize_safe_redirections(sanitized)
|
|
434
|
+
|
|
435
|
+
# Check for shell metacharacters in quoted arguments
|
|
436
|
+
if re.search(r'(?:^|\s)["\'][^"\']*[;&][^"\']*["\'](?:\s|$)', sanitized):
|
|
437
|
+
return ValidationResult(
|
|
438
|
+
behavior="ask",
|
|
439
|
+
message="Command contains shell metacharacters (;, |, or &) in arguments",
|
|
440
|
+
rule_suggestions=None,
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
# Check for dangerous metacharacters in find/grep arguments
|
|
444
|
+
for pattern in _DANGEROUS_METACHARACTER_PATTERNS:
|
|
445
|
+
if pattern.search(sanitized):
|
|
446
|
+
return ValidationResult(
|
|
447
|
+
behavior="ask",
|
|
448
|
+
message="Command contains shell metacharacters (;, |, or &) in arguments",
|
|
449
|
+
rule_suggestions=None,
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
# Check for variables in dangerous contexts (redirections or pipes)
|
|
453
|
+
if re.search(r"[<>|]\s*\$[A-Za-z_]", sanitized) or re.search(
|
|
454
|
+
r"\$[A-Za-z_][A-Za-z0-9_]*\s*[|<>]", sanitized
|
|
455
|
+
):
|
|
456
|
+
return ValidationResult(
|
|
457
|
+
behavior="ask",
|
|
458
|
+
message="Command contains variables in dangerous contexts (redirections or pipes)",
|
|
459
|
+
rule_suggestions=None,
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
# Check for sourcing scripts
|
|
463
|
+
# Note: `. ` followed by a path-like string (e.g., `. script.sh`, `. ./script`)
|
|
464
|
+
# but not `. -name` (find argument) or similar
|
|
465
|
+
if re.search(r"(^|\s)source\s+", sanitized):
|
|
466
|
+
return ValidationResult(
|
|
467
|
+
behavior="ask",
|
|
468
|
+
message="Command sources another script which may execute arbitrary code",
|
|
469
|
+
rule_suggestions=None,
|
|
470
|
+
)
|
|
471
|
+
# Match `. ` followed by a path (starts with /, ./, ~, or alphanumeric)
|
|
472
|
+
if re.search(r"(^|\s)\.\s+[/~\w]", sanitized):
|
|
473
|
+
# Exclude common find arguments like `. -name`
|
|
474
|
+
if not re.search(r"\.\s+-[a-z]", sanitized):
|
|
475
|
+
return ValidationResult(
|
|
476
|
+
behavior="ask",
|
|
477
|
+
message="Command sources another script which may execute arbitrary code",
|
|
478
|
+
rule_suggestions=None,
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
# Check for eval
|
|
482
|
+
if re.search(r"(^|\s)eval\s+", sanitized):
|
|
483
|
+
return ValidationResult(
|
|
484
|
+
behavior="ask",
|
|
485
|
+
message="Command uses eval which executes arbitrary code",
|
|
486
|
+
rule_suggestions=None,
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
# Check all dangerous patterns
|
|
490
|
+
for pattern, message in _DANGEROUS_PATTERNS:
|
|
491
|
+
if pattern.search(sanitized):
|
|
492
|
+
return ValidationResult(
|
|
493
|
+
behavior="ask",
|
|
494
|
+
message=message,
|
|
495
|
+
rule_suggestions=None,
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
return ValidationResult(
|
|
499
|
+
behavior="passthrough",
|
|
500
|
+
message="Command passed all security checks",
|
|
501
|
+
rule_suggestions=None,
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def is_complex_unsafe_shell_command(command: str) -> bool:
|
|
506
|
+
"""Check if a command contains complex shell operators that need special handling.
|
|
507
|
+
|
|
508
|
+
This detects commands with control operators like &&, ||, ;, etc. that
|
|
509
|
+
combine multiple commands.
|
|
510
|
+
"""
|
|
511
|
+
if not command:
|
|
512
|
+
return False
|
|
513
|
+
|
|
514
|
+
# Simple check for control operators outside of quotes
|
|
515
|
+
in_single_quote = False
|
|
516
|
+
in_double_quote = False
|
|
517
|
+
escaped = False
|
|
518
|
+
|
|
519
|
+
for i, char in enumerate(command):
|
|
520
|
+
if escaped:
|
|
521
|
+
escaped = False
|
|
522
|
+
continue
|
|
523
|
+
|
|
524
|
+
if char == "\\":
|
|
525
|
+
escaped = True
|
|
526
|
+
continue
|
|
527
|
+
|
|
528
|
+
if char == "'" and not in_double_quote:
|
|
529
|
+
in_single_quote = not in_single_quote
|
|
530
|
+
continue
|
|
531
|
+
|
|
532
|
+
if char == '"' and not in_single_quote:
|
|
533
|
+
in_double_quote = not in_double_quote
|
|
534
|
+
continue
|
|
535
|
+
|
|
536
|
+
# Outside of quotes, check for control operators
|
|
537
|
+
if not in_single_quote and not in_double_quote:
|
|
538
|
+
# Check for && or ||
|
|
539
|
+
if char in ("&", "|") and i + 1 < len(command):
|
|
540
|
+
next_char = command[i + 1]
|
|
541
|
+
if (char == "&" and next_char == "&") or (char == "|" and next_char == "|"):
|
|
542
|
+
return True
|
|
543
|
+
|
|
544
|
+
# Check for ; (but not ;;)
|
|
545
|
+
if char == ";":
|
|
546
|
+
if i + 1 >= len(command) or command[i + 1] != ";":
|
|
547
|
+
return True
|
|
548
|
+
|
|
549
|
+
return False
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
__all__ = ["validate_shell_command", "ValidationResult", "is_complex_unsafe_shell_command"]
|