ripperdoc 0.2.3__py3-none-any.whl → 0.2.5__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 (76) hide show
  1. ripperdoc/__init__.py +1 -1
  2. ripperdoc/__main__.py +0 -5
  3. ripperdoc/cli/cli.py +37 -16
  4. ripperdoc/cli/commands/__init__.py +2 -0
  5. ripperdoc/cli/commands/agents_cmd.py +12 -9
  6. ripperdoc/cli/commands/compact_cmd.py +7 -3
  7. ripperdoc/cli/commands/context_cmd.py +35 -15
  8. ripperdoc/cli/commands/doctor_cmd.py +27 -14
  9. ripperdoc/cli/commands/exit_cmd.py +1 -1
  10. ripperdoc/cli/commands/mcp_cmd.py +13 -8
  11. ripperdoc/cli/commands/memory_cmd.py +5 -5
  12. ripperdoc/cli/commands/models_cmd.py +47 -16
  13. ripperdoc/cli/commands/permissions_cmd.py +302 -0
  14. ripperdoc/cli/commands/resume_cmd.py +1 -2
  15. ripperdoc/cli/commands/tasks_cmd.py +24 -13
  16. ripperdoc/cli/ui/rich_ui.py +523 -396
  17. ripperdoc/cli/ui/tool_renderers.py +298 -0
  18. ripperdoc/core/agents.py +172 -4
  19. ripperdoc/core/config.py +130 -6
  20. ripperdoc/core/default_tools.py +13 -2
  21. ripperdoc/core/permissions.py +20 -14
  22. ripperdoc/core/providers/__init__.py +31 -15
  23. ripperdoc/core/providers/anthropic.py +122 -8
  24. ripperdoc/core/providers/base.py +93 -15
  25. ripperdoc/core/providers/gemini.py +539 -96
  26. ripperdoc/core/providers/openai.py +371 -26
  27. ripperdoc/core/query.py +301 -62
  28. ripperdoc/core/query_utils.py +51 -7
  29. ripperdoc/core/skills.py +295 -0
  30. ripperdoc/core/system_prompt.py +79 -67
  31. ripperdoc/core/tool.py +15 -6
  32. ripperdoc/sdk/client.py +14 -1
  33. ripperdoc/tools/ask_user_question_tool.py +431 -0
  34. ripperdoc/tools/background_shell.py +82 -26
  35. ripperdoc/tools/bash_tool.py +356 -209
  36. ripperdoc/tools/dynamic_mcp_tool.py +428 -0
  37. ripperdoc/tools/enter_plan_mode_tool.py +226 -0
  38. ripperdoc/tools/exit_plan_mode_tool.py +153 -0
  39. ripperdoc/tools/file_edit_tool.py +53 -10
  40. ripperdoc/tools/file_read_tool.py +17 -7
  41. ripperdoc/tools/file_write_tool.py +49 -13
  42. ripperdoc/tools/glob_tool.py +10 -9
  43. ripperdoc/tools/grep_tool.py +182 -51
  44. ripperdoc/tools/ls_tool.py +6 -6
  45. ripperdoc/tools/mcp_tools.py +172 -413
  46. ripperdoc/tools/multi_edit_tool.py +49 -9
  47. ripperdoc/tools/notebook_edit_tool.py +57 -13
  48. ripperdoc/tools/skill_tool.py +205 -0
  49. ripperdoc/tools/task_tool.py +91 -9
  50. ripperdoc/tools/todo_tool.py +12 -12
  51. ripperdoc/tools/tool_search_tool.py +5 -6
  52. ripperdoc/utils/coerce.py +34 -0
  53. ripperdoc/utils/context_length_errors.py +252 -0
  54. ripperdoc/utils/file_watch.py +5 -4
  55. ripperdoc/utils/json_utils.py +4 -4
  56. ripperdoc/utils/log.py +3 -3
  57. ripperdoc/utils/mcp.py +82 -22
  58. ripperdoc/utils/memory.py +9 -6
  59. ripperdoc/utils/message_compaction.py +19 -16
  60. ripperdoc/utils/messages.py +73 -8
  61. ripperdoc/utils/path_ignore.py +677 -0
  62. ripperdoc/utils/permissions/__init__.py +7 -1
  63. ripperdoc/utils/permissions/path_validation_utils.py +5 -3
  64. ripperdoc/utils/permissions/shell_command_validation.py +496 -18
  65. ripperdoc/utils/prompt.py +1 -1
  66. ripperdoc/utils/safe_get_cwd.py +5 -2
  67. ripperdoc/utils/session_history.py +38 -19
  68. ripperdoc/utils/todo.py +6 -2
  69. ripperdoc/utils/token_estimation.py +34 -0
  70. {ripperdoc-0.2.3.dist-info → ripperdoc-0.2.5.dist-info}/METADATA +14 -1
  71. ripperdoc-0.2.5.dist-info/RECORD +107 -0
  72. ripperdoc-0.2.3.dist-info/RECORD +0 -95
  73. {ripperdoc-0.2.3.dist-info → ripperdoc-0.2.5.dist-info}/WHEEL +0 -0
  74. {ripperdoc-0.2.3.dist-info → ripperdoc-0.2.5.dist-info}/entry_points.txt +0 -0
  75. {ripperdoc-0.2.3.dist-info → ripperdoc-0.2.5.dist-info}/licenses/LICENSE +0 -0
  76. {ripperdoc-0.2.3.dist-info → ripperdoc-0.2.5.dist-info}/top_level.txt +0 -0
@@ -1,32 +1,371 @@
1
- """Lightweight shell command validation heuristics."""
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
+ """
2
6
 
3
7
  from __future__ import annotations
4
8
 
5
9
  import re
6
10
  from dataclasses import dataclass
7
- from typing import List, Optional
11
+ from typing import List, Optional, Tuple
8
12
 
9
13
 
10
14
  @dataclass
11
15
  class ValidationResult:
16
+ """Result of shell command validation."""
17
+
12
18
  behavior: str # 'passthrough' | 'ask' | 'allow' | 'deny'
13
19
  message: str
14
20
  rule_suggestions: Optional[List[str]] = None
15
21
 
16
22
 
17
- _DANGEROUS_PATTERNS: list[tuple[re.Pattern[str], str]] = [
18
- (re.compile(r"[`‵]"), "Command contains backticks for command substitution"),
19
- (re.compile(r"\$\("), "Command contains $() command substitution"),
20
- (re.compile(r"\$\{"), "Command contains ${} parameter substitution"),
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
21
102
  (re.compile(r"<\("), "Command contains process substitution <()"),
22
103
  (re.compile(r">\("), "Command contains process substitution >()"),
23
- (re.compile(r"<<<?"), "Command contains heredoc redirection"),
24
- (re.compile(r"(^|\s)source\s+"), "Command sources another script"),
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
+ ),
25
168
  ]
26
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
+
27
356
 
28
357
  def validate_shell_command(shell_command: str) -> ValidationResult:
29
- """Validate a shell command for risky constructs."""
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
+ """
30
369
  if not shell_command or not shell_command.strip():
31
370
  return ValidationResult(
32
371
  behavior="passthrough",
@@ -35,14 +374,51 @@ def validate_shell_command(shell_command: str) -> ValidationResult:
35
374
  )
36
375
 
37
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
+ )
38
394
 
39
- if re.search(r"\bjq\b.*\bsystem\s*\(", trimmed):
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):
40
415
  return ValidationResult(
41
416
  behavior="ask",
42
- message="jq command contains system() which executes arbitrary commands",
417
+ message="Command contains heredoc with command substitution",
43
418
  rule_suggestions=None,
44
419
  )
45
420
 
421
+ # Check for heredoc patterns that could run arbitrary content
46
422
  if re.search(r"\b(cat|tee)\s+<<\s*['\"]?EOF", trimmed):
47
423
  return ValidationResult(
48
424
  behavior="ask",
@@ -50,12 +426,67 @@ def validate_shell_command(shell_command: str) -> ValidationResult:
50
426
  rule_suggestions=None,
51
427
  )
52
428
 
53
- if re.search(r"[<>]\s*/dev/null", trimmed):
54
- # Explicit /dev/null redirection is benign for our purposes.
55
- sanitized = re.sub(r"\s*[<>]\s*/dev/null", "", trimmed)
56
- else:
57
- sanitized = trimmed
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
+ )
58
488
 
489
+ # Check all dangerous patterns
59
490
  for pattern, message in _DANGEROUS_PATTERNS:
60
491
  if pattern.search(sanitized):
61
492
  return ValidationResult(
@@ -66,9 +497,56 @@ def validate_shell_command(shell_command: str) -> ValidationResult:
66
497
 
67
498
  return ValidationResult(
68
499
  behavior="passthrough",
69
- message="Command passed validation",
500
+ message="Command passed all security checks",
70
501
  rule_suggestions=None,
71
502
  )
72
503
 
73
504
 
74
- __all__ = ["validate_shell_command", "ValidationResult"]
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"]
ripperdoc/utils/prompt.py CHANGED
@@ -13,5 +13,5 @@ def prompt_secret(prompt_text: str, prompt_suffix: str = ": ") -> str:
13
13
  from prompt_toolkit import prompt as pt_prompt
14
14
 
15
15
  return pt_prompt(full_prompt, is_password=True)
16
- except Exception:
16
+ except (ImportError, OSError, RuntimeError, EOFError):
17
17
  return getpass(full_prompt)
@@ -20,8 +20,11 @@ def safe_get_cwd() -> str:
20
20
  """Return the current working directory, falling back to the original on error."""
21
21
  try:
22
22
  return str(Path(os.getcwd()).resolve())
23
- except Exception:
24
- logger.exception("[safe_get_cwd] Failed to resolve cwd")
23
+ except (OSError, RuntimeError, ValueError) as exc:
24
+ logger.warning(
25
+ "[safe_get_cwd] Failed to resolve cwd: %s: %s",
26
+ type(exc).__name__, exc,
27
+ )
25
28
  return get_original_cwd()
26
29
 
27
30