ripperdoc 0.2.9__py3-none-any.whl → 0.2.10__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 (45) hide show
  1. ripperdoc/__init__.py +1 -1
  2. ripperdoc/cli/cli.py +235 -14
  3. ripperdoc/cli/commands/__init__.py +2 -0
  4. ripperdoc/cli/commands/agents_cmd.py +132 -5
  5. ripperdoc/cli/commands/clear_cmd.py +8 -0
  6. ripperdoc/cli/commands/exit_cmd.py +1 -0
  7. ripperdoc/cli/commands/models_cmd.py +3 -3
  8. ripperdoc/cli/commands/resume_cmd.py +4 -0
  9. ripperdoc/cli/commands/stats_cmd.py +244 -0
  10. ripperdoc/cli/ui/panels.py +1 -0
  11. ripperdoc/cli/ui/rich_ui.py +295 -24
  12. ripperdoc/cli/ui/spinner.py +30 -18
  13. ripperdoc/cli/ui/thinking_spinner.py +1 -2
  14. ripperdoc/cli/ui/wizard.py +6 -8
  15. ripperdoc/core/agents.py +10 -3
  16. ripperdoc/core/config.py +3 -6
  17. ripperdoc/core/default_tools.py +90 -10
  18. ripperdoc/core/hooks/events.py +4 -0
  19. ripperdoc/core/hooks/llm_callback.py +59 -0
  20. ripperdoc/core/permissions.py +78 -4
  21. ripperdoc/core/providers/openai.py +29 -19
  22. ripperdoc/core/query.py +192 -31
  23. ripperdoc/core/tool.py +9 -4
  24. ripperdoc/sdk/client.py +77 -2
  25. ripperdoc/tools/background_shell.py +305 -134
  26. ripperdoc/tools/bash_tool.py +42 -13
  27. ripperdoc/tools/file_edit_tool.py +159 -50
  28. ripperdoc/tools/file_read_tool.py +20 -0
  29. ripperdoc/tools/file_write_tool.py +7 -8
  30. ripperdoc/tools/lsp_tool.py +615 -0
  31. ripperdoc/tools/task_tool.py +514 -65
  32. ripperdoc/utils/conversation_compaction.py +1 -1
  33. ripperdoc/utils/file_watch.py +206 -3
  34. ripperdoc/utils/lsp.py +806 -0
  35. ripperdoc/utils/message_formatting.py +5 -2
  36. ripperdoc/utils/messages.py +21 -1
  37. ripperdoc/utils/permissions/tool_permission_utils.py +174 -15
  38. ripperdoc/utils/session_heatmap.py +244 -0
  39. ripperdoc/utils/session_stats.py +293 -0
  40. {ripperdoc-0.2.9.dist-info → ripperdoc-0.2.10.dist-info}/METADATA +8 -2
  41. {ripperdoc-0.2.9.dist-info → ripperdoc-0.2.10.dist-info}/RECORD +45 -39
  42. {ripperdoc-0.2.9.dist-info → ripperdoc-0.2.10.dist-info}/WHEEL +0 -0
  43. {ripperdoc-0.2.9.dist-info → ripperdoc-0.2.10.dist-info}/entry_points.txt +0 -0
  44. {ripperdoc-0.2.9.dist-info → ripperdoc-0.2.10.dist-info}/licenses/LICENSE +0 -0
  45. {ripperdoc-0.2.9.dist-info → ripperdoc-0.2.10.dist-info}/top_level.txt +0 -0
@@ -3,9 +3,11 @@
3
3
  Allows the AI to edit files by replacing text.
4
4
  """
5
5
 
6
+ import contextlib
6
7
  import os
8
+ import tempfile
7
9
  from pathlib import Path
8
- from typing import AsyncGenerator, List, Optional
10
+ from typing import AsyncGenerator, Generator, List, Optional, TextIO
9
11
  from pydantic import BaseModel, Field
10
12
 
11
13
  from ripperdoc.core.tool import (
@@ -20,9 +22,42 @@ from ripperdoc.utils.log import get_logger
20
22
  from ripperdoc.utils.file_watch import record_snapshot
21
23
  from ripperdoc.utils.path_ignore import check_path_for_tool
22
24
 
25
+ # Import fcntl for file locking on Unix systems
26
+ try:
27
+ import fcntl
28
+
29
+ HAS_FCNTL = True
30
+ except ImportError:
31
+ HAS_FCNTL = False
32
+
23
33
  logger = get_logger()
24
34
 
25
35
 
36
+ @contextlib.contextmanager
37
+ def _file_lock(file_handle: TextIO, exclusive: bool = True) -> Generator[None, None, None]:
38
+ """Acquire a file lock, with fallback for systems without fcntl.
39
+
40
+ Args:
41
+ file_handle: An open file handle to lock
42
+ exclusive: If True, acquire exclusive lock; otherwise shared lock
43
+
44
+ Yields:
45
+ None
46
+ """
47
+ if not HAS_FCNTL:
48
+ # On Windows or systems without fcntl, skip locking
49
+ yield
50
+ return
51
+
52
+ lock_type = fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH
53
+ try:
54
+ fcntl.flock(file_handle.fileno(), lock_type)
55
+ yield
56
+ finally:
57
+ with contextlib.suppress(OSError):
58
+ fcntl.flock(file_handle.fileno(), fcntl.LOCK_UN)
59
+
60
+
26
61
  class FileEditToolInput(BaseModel):
27
62
  """Input schema for FileEditTool."""
28
63
 
@@ -180,57 +215,131 @@ match exactly (including whitespace and indentation)."""
180
215
  async def call(
181
216
  self, input_data: FileEditToolInput, context: ToolUseContext
182
217
  ) -> AsyncGenerator[ToolOutput, None]:
183
- """Edit the file."""
218
+ """Edit the file with TOCTOU protection."""
219
+
220
+ abs_file_path = os.path.abspath(input_data.file_path)
221
+ file_state_cache = getattr(context, "file_state_cache", {})
222
+ file_snapshot = file_state_cache.get(abs_file_path)
184
223
 
185
224
  try:
186
- # Read the file
187
- with open(input_data.file_path, "r", encoding="utf-8") as f:
188
- content = f.read()
189
-
190
- # Check if old_string exists
191
- if input_data.old_string not in content:
192
- output = FileEditToolOutput(
193
- file_path=input_data.file_path,
194
- replacements_made=0,
195
- success=False,
196
- message=f"String not found in file: {input_data.file_path}",
197
- )
198
- yield ToolResult(
199
- data=output, result_for_assistant=self.render_result_for_assistant(output)
200
- )
201
- return
202
-
203
- # Count occurrences
204
- occurrence_count = content.count(input_data.old_string)
205
-
206
- # Check for ambiguity if not replace_all
207
- if not input_data.replace_all and occurrence_count > 1:
208
- output = FileEditToolOutput(
209
- file_path=input_data.file_path,
210
- replacements_made=0,
211
- success=False,
212
- message=f"String appears {occurrence_count} times in file. "
213
- f"Either provide a unique string or use replace_all=true",
214
- )
215
- yield ToolResult(
216
- data=output, result_for_assistant=self.render_result_for_assistant(output)
217
- )
218
- return
219
-
220
- # Perform replacement
221
- if input_data.replace_all:
222
- new_content = content.replace(input_data.old_string, input_data.new_string)
223
- replacements = occurrence_count
224
- else:
225
- new_content = content.replace(input_data.old_string, input_data.new_string, 1)
226
- replacements = 1
227
-
228
- # Write the file
229
- with open(input_data.file_path, "w", encoding="utf-8") as f:
230
- f.write(new_content)
231
-
232
- # Use absolute path to ensure consistency with validation lookup
233
- abs_file_path = os.path.abspath(input_data.file_path)
225
+ # Open file with exclusive lock to prevent concurrent modifications
226
+ # Use r+ mode to get a file handle we can lock before reading
227
+ with open(abs_file_path, "r+", encoding="utf-8") as f:
228
+ with _file_lock(f, exclusive=True):
229
+ # Re-check mtime AFTER acquiring lock to close TOCTOU window
230
+ # This is the key fix: validate mtime while holding the lock
231
+ if file_snapshot:
232
+ try:
233
+ current_mtime = os.fstat(f.fileno()).st_mtime
234
+ if current_mtime > file_snapshot.timestamp:
235
+ output = FileEditToolOutput(
236
+ file_path=input_data.file_path,
237
+ replacements_made=0,
238
+ success=False,
239
+ message="File has been modified since read, either by the user "
240
+ "or by a linter. Read it again before attempting to edit it.",
241
+ )
242
+ yield ToolResult(
243
+ data=output,
244
+ result_for_assistant=self.render_result_for_assistant(output),
245
+ )
246
+ return
247
+ except OSError:
248
+ pass # fstat failed, proceed anyway
249
+
250
+ # Read content while holding the lock
251
+ content = f.read()
252
+
253
+ # Check if old_string exists
254
+ if input_data.old_string not in content:
255
+ output = FileEditToolOutput(
256
+ file_path=input_data.file_path,
257
+ replacements_made=0,
258
+ success=False,
259
+ message=f"String not found in file: {input_data.file_path}",
260
+ )
261
+ yield ToolResult(
262
+ data=output,
263
+ result_for_assistant=self.render_result_for_assistant(output),
264
+ )
265
+ return
266
+
267
+ # Count occurrences
268
+ occurrence_count = content.count(input_data.old_string)
269
+
270
+ # Check for ambiguity if not replace_all
271
+ if not input_data.replace_all and occurrence_count > 1:
272
+ output = FileEditToolOutput(
273
+ file_path=input_data.file_path,
274
+ replacements_made=0,
275
+ success=False,
276
+ message=f"String appears {occurrence_count} times in file. "
277
+ f"Either provide a unique string or use replace_all=true",
278
+ )
279
+ yield ToolResult(
280
+ data=output,
281
+ result_for_assistant=self.render_result_for_assistant(output),
282
+ )
283
+ return
284
+
285
+ # Perform replacement
286
+ if input_data.replace_all:
287
+ new_content = content.replace(input_data.old_string, input_data.new_string)
288
+ replacements = occurrence_count
289
+ else:
290
+ new_content = content.replace(
291
+ input_data.old_string, input_data.new_string, 1
292
+ )
293
+ replacements = 1
294
+
295
+ # Atomic write: write to temp file then rename
296
+ # This ensures the file is either fully written or not at all
297
+ file_dir = os.path.dirname(abs_file_path)
298
+ try:
299
+ # Create temp file in same directory to ensure same filesystem
300
+ fd, temp_path = tempfile.mkstemp(
301
+ dir=file_dir, prefix=".ripperdoc_edit_", suffix=".tmp"
302
+ )
303
+ try:
304
+ with os.fdopen(fd, "w", encoding="utf-8") as temp_f:
305
+ temp_f.write(new_content)
306
+ # Preserve original file permissions
307
+ original_stat = os.fstat(f.fileno())
308
+ os.chmod(temp_path, original_stat.st_mode)
309
+ # Atomic replace (works on Unix, best-effort on Windows)
310
+ os.replace(temp_path, abs_file_path)
311
+ except Exception:
312
+ # Clean up temp file on failure
313
+ with contextlib.suppress(OSError):
314
+ os.unlink(temp_path)
315
+ raise
316
+ except OSError as atomic_error:
317
+ # Fallback to in-place write if atomic write fails
318
+ # (e.g., cross-filesystem issues)
319
+ # Re-verify file hasn't changed before fallback write (TOCTOU protection)
320
+ f.seek(0)
321
+ current_content = f.read()
322
+ if current_content != content:
323
+ output = FileEditToolOutput(
324
+ file_path=input_data.file_path,
325
+ replacements_made=0,
326
+ success=False,
327
+ message="File was modified during atomic write fallback. Please retry.",
328
+ )
329
+ yield ToolResult(
330
+ data=output,
331
+ result_for_assistant=self.render_result_for_assistant(output),
332
+ )
333
+ return
334
+ f.seek(0)
335
+ f.truncate()
336
+ f.write(new_content)
337
+ logger.debug(
338
+ "[file_edit_tool] Atomic write failed, used fallback: %s",
339
+ atomic_error,
340
+ )
341
+
342
+ # Record the new snapshot after successful edit
234
343
  try:
235
344
  record_snapshot(
236
345
  abs_file_path,
@@ -22,6 +22,10 @@ from ripperdoc.utils.path_ignore import check_path_for_tool
22
22
 
23
23
  logger = get_logger()
24
24
 
25
+ # Maximum file size to read (default 50MB, configurable via env)
26
+ MAX_FILE_SIZE_MB = float(os.getenv("RIPPERDOC_MAX_READ_FILE_SIZE_MB", "50"))
27
+ MAX_FILE_SIZE_BYTES = int(MAX_FILE_SIZE_MB * 1024 * 1024)
28
+
25
29
 
26
30
  class FileReadToolInput(BaseModel):
27
31
  """Input schema for FileReadTool."""
@@ -140,6 +144,22 @@ and limit to read only a portion of the file."""
140
144
  """Read the file."""
141
145
 
142
146
  try:
147
+ # Check file size before reading to prevent memory exhaustion
148
+ file_size = os.path.getsize(input_data.file_path)
149
+ if file_size > MAX_FILE_SIZE_BYTES:
150
+ error_output = FileReadToolOutput(
151
+ content=f"File too large to read: {file_size / (1024*1024):.1f}MB exceeds limit of {MAX_FILE_SIZE_MB}MB. Use offset and limit parameters to read portions.",
152
+ file_path=input_data.file_path,
153
+ line_count=0,
154
+ offset=0,
155
+ limit=None,
156
+ )
157
+ yield ToolResult(
158
+ data=error_output,
159
+ result_for_assistant=f"Error: File {input_data.file_path} is too large ({file_size / (1024*1024):.1f}MB). Maximum size is {MAX_FILE_SIZE_MB}MB. Use offset and limit to read portions.",
160
+ )
161
+ return
162
+
143
163
  with open(input_data.file_path, "r", encoding="utf-8", errors="replace") as f:
144
164
  lines = f.readlines()
145
165
 
@@ -104,6 +104,13 @@ NEVER write new files unless explicitly required by the user."""
104
104
 
105
105
  file_path = os.path.abspath(input_data.file_path)
106
106
 
107
+ file_path_obj = Path(file_path)
108
+ should_proceed, warning_msg = check_path_for_tool(
109
+ file_path_obj, tool_name="Write", warn_only=True
110
+ )
111
+ if warning_msg:
112
+ logger.warning("[file_write_tool] %s", warning_msg)
113
+
107
114
  # If file doesn't exist, it's a new file - allow without reading first
108
115
  if not os.path.exists(file_path):
109
116
  return ValidationResult(result=True)
@@ -132,14 +139,6 @@ NEVER write new files unless explicitly required by the user."""
132
139
  except OSError:
133
140
  pass # File mtime check failed, proceed anyway
134
141
 
135
- # Check if path is ignored (warning for write operations)
136
- file_path_obj = Path(file_path)
137
- should_proceed, warning_msg = check_path_for_tool(
138
- file_path_obj, tool_name="Write", warn_only=True
139
- )
140
- if warning_msg:
141
- logger.warning("[file_write_tool] %s", warning_msg)
142
-
143
142
  return ValidationResult(result=True)
144
143
 
145
144
  def render_result_for_assistant(self, output: FileWriteToolOutput) -> str: