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.
Files changed (107) hide show
  1. ripperdoc/__init__.py +3 -0
  2. ripperdoc/__main__.py +20 -0
  3. ripperdoc/cli/__init__.py +1 -0
  4. ripperdoc/cli/cli.py +405 -0
  5. ripperdoc/cli/commands/__init__.py +82 -0
  6. ripperdoc/cli/commands/agents_cmd.py +263 -0
  7. ripperdoc/cli/commands/base.py +19 -0
  8. ripperdoc/cli/commands/clear_cmd.py +18 -0
  9. ripperdoc/cli/commands/compact_cmd.py +23 -0
  10. ripperdoc/cli/commands/config_cmd.py +31 -0
  11. ripperdoc/cli/commands/context_cmd.py +144 -0
  12. ripperdoc/cli/commands/cost_cmd.py +82 -0
  13. ripperdoc/cli/commands/doctor_cmd.py +221 -0
  14. ripperdoc/cli/commands/exit_cmd.py +19 -0
  15. ripperdoc/cli/commands/help_cmd.py +20 -0
  16. ripperdoc/cli/commands/mcp_cmd.py +70 -0
  17. ripperdoc/cli/commands/memory_cmd.py +202 -0
  18. ripperdoc/cli/commands/models_cmd.py +413 -0
  19. ripperdoc/cli/commands/permissions_cmd.py +302 -0
  20. ripperdoc/cli/commands/resume_cmd.py +98 -0
  21. ripperdoc/cli/commands/status_cmd.py +167 -0
  22. ripperdoc/cli/commands/tasks_cmd.py +278 -0
  23. ripperdoc/cli/commands/todos_cmd.py +69 -0
  24. ripperdoc/cli/commands/tools_cmd.py +19 -0
  25. ripperdoc/cli/ui/__init__.py +1 -0
  26. ripperdoc/cli/ui/context_display.py +298 -0
  27. ripperdoc/cli/ui/helpers.py +22 -0
  28. ripperdoc/cli/ui/rich_ui.py +1557 -0
  29. ripperdoc/cli/ui/spinner.py +49 -0
  30. ripperdoc/cli/ui/thinking_spinner.py +128 -0
  31. ripperdoc/cli/ui/tool_renderers.py +298 -0
  32. ripperdoc/core/__init__.py +1 -0
  33. ripperdoc/core/agents.py +486 -0
  34. ripperdoc/core/commands.py +33 -0
  35. ripperdoc/core/config.py +559 -0
  36. ripperdoc/core/default_tools.py +88 -0
  37. ripperdoc/core/permissions.py +252 -0
  38. ripperdoc/core/providers/__init__.py +47 -0
  39. ripperdoc/core/providers/anthropic.py +250 -0
  40. ripperdoc/core/providers/base.py +265 -0
  41. ripperdoc/core/providers/gemini.py +615 -0
  42. ripperdoc/core/providers/openai.py +487 -0
  43. ripperdoc/core/query.py +1058 -0
  44. ripperdoc/core/query_utils.py +622 -0
  45. ripperdoc/core/skills.py +295 -0
  46. ripperdoc/core/system_prompt.py +431 -0
  47. ripperdoc/core/tool.py +240 -0
  48. ripperdoc/sdk/__init__.py +9 -0
  49. ripperdoc/sdk/client.py +333 -0
  50. ripperdoc/tools/__init__.py +1 -0
  51. ripperdoc/tools/ask_user_question_tool.py +431 -0
  52. ripperdoc/tools/background_shell.py +389 -0
  53. ripperdoc/tools/bash_output_tool.py +98 -0
  54. ripperdoc/tools/bash_tool.py +1016 -0
  55. ripperdoc/tools/dynamic_mcp_tool.py +428 -0
  56. ripperdoc/tools/enter_plan_mode_tool.py +226 -0
  57. ripperdoc/tools/exit_plan_mode_tool.py +153 -0
  58. ripperdoc/tools/file_edit_tool.py +346 -0
  59. ripperdoc/tools/file_read_tool.py +203 -0
  60. ripperdoc/tools/file_write_tool.py +205 -0
  61. ripperdoc/tools/glob_tool.py +179 -0
  62. ripperdoc/tools/grep_tool.py +370 -0
  63. ripperdoc/tools/kill_bash_tool.py +136 -0
  64. ripperdoc/tools/ls_tool.py +471 -0
  65. ripperdoc/tools/mcp_tools.py +591 -0
  66. ripperdoc/tools/multi_edit_tool.py +456 -0
  67. ripperdoc/tools/notebook_edit_tool.py +386 -0
  68. ripperdoc/tools/skill_tool.py +205 -0
  69. ripperdoc/tools/task_tool.py +379 -0
  70. ripperdoc/tools/todo_tool.py +494 -0
  71. ripperdoc/tools/tool_search_tool.py +380 -0
  72. ripperdoc/utils/__init__.py +1 -0
  73. ripperdoc/utils/bash_constants.py +51 -0
  74. ripperdoc/utils/bash_output_utils.py +43 -0
  75. ripperdoc/utils/coerce.py +34 -0
  76. ripperdoc/utils/context_length_errors.py +252 -0
  77. ripperdoc/utils/exit_code_handlers.py +241 -0
  78. ripperdoc/utils/file_watch.py +135 -0
  79. ripperdoc/utils/git_utils.py +274 -0
  80. ripperdoc/utils/json_utils.py +27 -0
  81. ripperdoc/utils/log.py +176 -0
  82. ripperdoc/utils/mcp.py +560 -0
  83. ripperdoc/utils/memory.py +253 -0
  84. ripperdoc/utils/message_compaction.py +676 -0
  85. ripperdoc/utils/messages.py +519 -0
  86. ripperdoc/utils/output_utils.py +258 -0
  87. ripperdoc/utils/path_ignore.py +677 -0
  88. ripperdoc/utils/path_utils.py +46 -0
  89. ripperdoc/utils/permissions/__init__.py +27 -0
  90. ripperdoc/utils/permissions/path_validation_utils.py +174 -0
  91. ripperdoc/utils/permissions/shell_command_validation.py +552 -0
  92. ripperdoc/utils/permissions/tool_permission_utils.py +279 -0
  93. ripperdoc/utils/prompt.py +17 -0
  94. ripperdoc/utils/safe_get_cwd.py +31 -0
  95. ripperdoc/utils/sandbox_utils.py +38 -0
  96. ripperdoc/utils/session_history.py +260 -0
  97. ripperdoc/utils/session_usage.py +117 -0
  98. ripperdoc/utils/shell_token_utils.py +95 -0
  99. ripperdoc/utils/shell_utils.py +159 -0
  100. ripperdoc/utils/todo.py +203 -0
  101. ripperdoc/utils/token_estimation.py +34 -0
  102. ripperdoc-0.2.6.dist-info/METADATA +193 -0
  103. ripperdoc-0.2.6.dist-info/RECORD +107 -0
  104. ripperdoc-0.2.6.dist-info/WHEEL +5 -0
  105. ripperdoc-0.2.6.dist-info/entry_points.txt +3 -0
  106. ripperdoc-0.2.6.dist-info/licenses/LICENSE +53 -0
  107. 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"]