ripperdoc 0.2.8__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 (94) hide show
  1. ripperdoc/__init__.py +1 -1
  2. ripperdoc/cli/cli.py +257 -123
  3. ripperdoc/cli/commands/__init__.py +2 -1
  4. ripperdoc/cli/commands/agents_cmd.py +138 -8
  5. ripperdoc/cli/commands/clear_cmd.py +9 -4
  6. ripperdoc/cli/commands/config_cmd.py +1 -1
  7. ripperdoc/cli/commands/context_cmd.py +3 -2
  8. ripperdoc/cli/commands/doctor_cmd.py +18 -4
  9. ripperdoc/cli/commands/exit_cmd.py +1 -0
  10. ripperdoc/cli/commands/hooks_cmd.py +27 -53
  11. ripperdoc/cli/commands/models_cmd.py +27 -10
  12. ripperdoc/cli/commands/permissions_cmd.py +27 -9
  13. ripperdoc/cli/commands/resume_cmd.py +9 -3
  14. ripperdoc/cli/commands/stats_cmd.py +244 -0
  15. ripperdoc/cli/commands/status_cmd.py +4 -4
  16. ripperdoc/cli/commands/tasks_cmd.py +8 -4
  17. ripperdoc/cli/ui/file_mention_completer.py +2 -1
  18. ripperdoc/cli/ui/interrupt_handler.py +2 -3
  19. ripperdoc/cli/ui/message_display.py +4 -2
  20. ripperdoc/cli/ui/panels.py +1 -0
  21. ripperdoc/cli/ui/provider_options.py +247 -0
  22. ripperdoc/cli/ui/rich_ui.py +403 -81
  23. ripperdoc/cli/ui/spinner.py +54 -18
  24. ripperdoc/cli/ui/thinking_spinner.py +1 -2
  25. ripperdoc/cli/ui/tool_renderers.py +8 -2
  26. ripperdoc/cli/ui/wizard.py +213 -0
  27. ripperdoc/core/agents.py +19 -6
  28. ripperdoc/core/config.py +51 -17
  29. ripperdoc/core/custom_commands.py +7 -6
  30. ripperdoc/core/default_tools.py +101 -12
  31. ripperdoc/core/hooks/config.py +1 -3
  32. ripperdoc/core/hooks/events.py +27 -28
  33. ripperdoc/core/hooks/executor.py +4 -6
  34. ripperdoc/core/hooks/integration.py +12 -21
  35. ripperdoc/core/hooks/llm_callback.py +59 -0
  36. ripperdoc/core/hooks/manager.py +40 -15
  37. ripperdoc/core/permissions.py +118 -12
  38. ripperdoc/core/providers/anthropic.py +109 -36
  39. ripperdoc/core/providers/gemini.py +70 -5
  40. ripperdoc/core/providers/openai.py +89 -24
  41. ripperdoc/core/query.py +273 -68
  42. ripperdoc/core/query_utils.py +2 -0
  43. ripperdoc/core/skills.py +9 -3
  44. ripperdoc/core/system_prompt.py +4 -2
  45. ripperdoc/core/tool.py +17 -8
  46. ripperdoc/sdk/client.py +79 -4
  47. ripperdoc/tools/ask_user_question_tool.py +5 -3
  48. ripperdoc/tools/background_shell.py +307 -135
  49. ripperdoc/tools/bash_output_tool.py +1 -1
  50. ripperdoc/tools/bash_tool.py +63 -24
  51. ripperdoc/tools/dynamic_mcp_tool.py +29 -8
  52. ripperdoc/tools/enter_plan_mode_tool.py +1 -1
  53. ripperdoc/tools/exit_plan_mode_tool.py +1 -1
  54. ripperdoc/tools/file_edit_tool.py +167 -54
  55. ripperdoc/tools/file_read_tool.py +28 -4
  56. ripperdoc/tools/file_write_tool.py +13 -10
  57. ripperdoc/tools/glob_tool.py +3 -2
  58. ripperdoc/tools/grep_tool.py +3 -2
  59. ripperdoc/tools/kill_bash_tool.py +1 -1
  60. ripperdoc/tools/ls_tool.py +1 -1
  61. ripperdoc/tools/lsp_tool.py +615 -0
  62. ripperdoc/tools/mcp_tools.py +13 -10
  63. ripperdoc/tools/multi_edit_tool.py +8 -7
  64. ripperdoc/tools/notebook_edit_tool.py +7 -4
  65. ripperdoc/tools/skill_tool.py +1 -1
  66. ripperdoc/tools/task_tool.py +519 -69
  67. ripperdoc/tools/todo_tool.py +2 -2
  68. ripperdoc/tools/tool_search_tool.py +3 -2
  69. ripperdoc/utils/conversation_compaction.py +9 -5
  70. ripperdoc/utils/file_watch.py +214 -5
  71. ripperdoc/utils/json_utils.py +2 -1
  72. ripperdoc/utils/lsp.py +806 -0
  73. ripperdoc/utils/mcp.py +11 -3
  74. ripperdoc/utils/memory.py +4 -2
  75. ripperdoc/utils/message_compaction.py +21 -7
  76. ripperdoc/utils/message_formatting.py +14 -7
  77. ripperdoc/utils/messages.py +126 -67
  78. ripperdoc/utils/path_ignore.py +35 -8
  79. ripperdoc/utils/permissions/path_validation_utils.py +2 -1
  80. ripperdoc/utils/permissions/shell_command_validation.py +427 -91
  81. ripperdoc/utils/permissions/tool_permission_utils.py +174 -15
  82. ripperdoc/utils/safe_get_cwd.py +2 -1
  83. ripperdoc/utils/session_heatmap.py +244 -0
  84. ripperdoc/utils/session_history.py +13 -6
  85. ripperdoc/utils/session_stats.py +293 -0
  86. ripperdoc/utils/todo.py +2 -1
  87. ripperdoc/utils/token_estimation.py +6 -1
  88. {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.10.dist-info}/METADATA +8 -2
  89. ripperdoc-0.2.10.dist-info/RECORD +129 -0
  90. ripperdoc-0.2.8.dist-info/RECORD +0 -121
  91. {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.10.dist-info}/WHEEL +0 -0
  92. {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.10.dist-info}/entry_points.txt +0 -0
  93. {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.10.dist-info}/licenses/LICENSE +0 -0
  94. {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.10.dist-info}/top_level.txt +0 -0
@@ -41,7 +41,6 @@ from ripperdoc.utils.output_utils import (
41
41
  truncate_output,
42
42
  )
43
43
  from ripperdoc.utils.permissions.path_validation_utils import validate_shell_command_paths
44
- from ripperdoc.utils.permissions.shell_command_validation import validate_shell_command
45
44
  from ripperdoc.utils.permissions.tool_permission_utils import (
46
45
  evaluate_shell_command_permissions,
47
46
  is_command_read_only,
@@ -150,7 +149,7 @@ build projects, run tests, and interact with the file system."""
150
149
  ),
151
150
  ]
152
151
 
153
- async def prompt(self, safe_mode: bool = False) -> str:
152
+ async def prompt(self, yolo_mode: bool = False) -> str:
154
153
  sandbox_available = is_sandbox_available()
155
154
  try:
156
155
  current_shell = find_suitable_shell()
@@ -341,9 +340,8 @@ build projects, run tests, and interact with the file system."""
341
340
  allow_rules,
342
341
  deny_rules,
343
342
  allowed_dirs,
344
- command_injection_detected=False,
345
- injection_detector=lambda cmd: validate_shell_command(cmd).behavior != "passthrough",
346
- read_only_detector=lambda cmd, detector: is_command_read_only(cmd),
343
+ # danger_detector uses default: validate_shell_command(cmd).behavior != "passthrough"
344
+ # read_only_detector uses default: _is_command_read_only
347
345
  )
348
346
 
349
347
  # Background executions need an explicit confirmation even if heuristics
@@ -384,6 +382,43 @@ build projects, run tests, and interact with the file system."""
384
382
  result=False, message="Sandbox mode requested but not available."
385
383
  )
386
384
 
385
+ # Validate shell_executable if provided
386
+ if input_data.shell_executable:
387
+ shell_path = Path(input_data.shell_executable)
388
+ # Must be an absolute path
389
+ if not shell_path.is_absolute():
390
+ return ValidationResult(
391
+ result=False,
392
+ message=f"shell_executable must be an absolute path: {input_data.shell_executable}",
393
+ )
394
+ # Must exist and be a file
395
+ if not shell_path.exists():
396
+ return ValidationResult(
397
+ result=False,
398
+ message=f"shell_executable not found: {input_data.shell_executable}",
399
+ )
400
+ if not shell_path.is_file():
401
+ return ValidationResult(
402
+ result=False,
403
+ message=f"shell_executable is not a file: {input_data.shell_executable}",
404
+ )
405
+ # Must be executable
406
+ if not os.access(shell_path, os.X_OK):
407
+ return ValidationResult(
408
+ result=False,
409
+ message=f"shell_executable is not executable: {input_data.shell_executable}",
410
+ )
411
+ # Must be in a safe system directory or match known shell patterns
412
+ safe_dirs = {"/bin", "/usr/bin", "/usr/local/bin", "/opt/homebrew/bin"}
413
+ shell_name = shell_path.name.lower()
414
+ known_shells = {"bash", "sh", "zsh", "fish", "dash", "ksh", "tcsh", "csh"}
415
+ parent_dir = str(shell_path.parent)
416
+ if parent_dir not in safe_dirs and shell_name not in known_shells:
417
+ return ValidationResult(
418
+ result=False,
419
+ message=f"shell_executable must be a known shell in a standard location: {input_data.shell_executable}",
420
+ )
421
+
387
422
  # Note: Path validation for sensitive directories (cd/find to /usr, /etc, etc.)
388
423
  # is now handled in check_permissions() to allow user confirmation for read-only ops.
389
424
 
@@ -396,10 +431,9 @@ build projects, run tests, and interact with the file system."""
396
431
  result=False, message="This command cannot be run in background"
397
432
  )
398
433
 
399
- validation = validate_shell_command(input_data.command)
400
- if validation.behavior == "ask":
401
- return ValidationResult(result=False, message=validation.message)
402
-
434
+ # Note: Security validation (shell metacharacters, destructive commands, etc.)
435
+ # is handled in check_permissions() via evaluate_shell_command_permissions().
436
+ # validate_input() should only perform format/parameter validation.
403
437
  return ValidationResult(result=True)
404
438
 
405
439
  def render_result_for_assistant(self, output: BashToolOutput) -> str:
@@ -505,9 +539,7 @@ build projects, run tests, and interact with the file system."""
505
539
 
506
540
  return command, False
507
541
 
508
- def _create_error_output(
509
- self, command: str, stderr: str, sandbox: bool
510
- ) -> BashToolOutput:
542
+ def _create_error_output(self, command: str, stderr: str, sandbox: bool) -> BashToolOutput:
511
543
  """Create a standardized error output."""
512
544
  return BashToolOutput(
513
545
  stdout="",
@@ -531,9 +563,13 @@ build projects, run tests, and interact with the file system."""
531
563
  return command, None, None
532
564
 
533
565
  if not is_sandbox_available():
534
- return None, self._create_error_output(
535
- command, "Sandbox mode requested but not available on this system", True
536
- ), None
566
+ return (
567
+ None,
568
+ self._create_error_output(
569
+ command, "Sandbox mode requested but not available on this system", True
570
+ ),
571
+ None,
572
+ )
537
573
 
538
574
  try:
539
575
  wrapper = create_sandbox_wrapper(command)
@@ -541,12 +577,15 @@ build projects, run tests, and interact with the file system."""
541
577
  except (OSError, RuntimeError, ValueError) as exc:
542
578
  logger.warning(
543
579
  "[bash_tool] Failed to enable sandbox: %s: %s",
544
- type(exc).__name__, exc,
580
+ type(exc).__name__,
581
+ exc,
545
582
  extra={"command": command},
546
583
  )
547
- return None, self._create_error_output(
548
- command, f"Failed to enable sandbox: {exc}", True
549
- ), None
584
+ return (
585
+ None,
586
+ self._create_error_output(command, f"Failed to enable sandbox: {exc}", True),
587
+ None,
588
+ )
550
589
 
551
590
  async def _run_background_command(
552
591
  self,
@@ -570,7 +609,8 @@ build projects, run tests, and interact with the file system."""
570
609
  # pragma: no cover - defensive import
571
610
  logger.warning(
572
611
  "[bash_tool] Failed to import background shell runner: %s: %s",
573
- type(e).__name__, e,
612
+ type(e).__name__,
613
+ e,
574
614
  extra={"command": effective_command},
575
615
  )
576
616
  return self._create_error_output(
@@ -696,9 +736,7 @@ build projects, run tests, and interact with the file system."""
696
736
  # Store results in a way that the caller can access
697
737
  self._last_execution_result = (stdout_lines, stderr_lines, timed_out)
698
738
 
699
- async def _drain_stream(
700
- self, stream: Optional[asyncio.StreamReader], sink: list[str]
701
- ) -> None:
739
+ async def _drain_stream(self, stream: Optional[asyncio.StreamReader], sink: list[str]) -> None:
702
740
  """Drain any remaining data from a stream."""
703
741
  if not stream:
704
742
  return
@@ -969,7 +1007,8 @@ build projects, run tests, and interact with the file system."""
969
1007
  raise # Re-raise cancellation
970
1008
  logger.warning(
971
1009
  "[bash_tool] Error executing command: %s: %s",
972
- type(e).__name__, e,
1010
+ type(e).__name__,
1011
+ e,
973
1012
  extra={"command": effective_command},
974
1013
  )
975
1014
  error_output = self._create_error_output(
@@ -74,7 +74,8 @@ def _annotation_flag(tool_info: Any, key: str) -> bool:
74
74
  except (AttributeError, TypeError, KeyError) as exc:
75
75
  logger.debug(
76
76
  "[mcp_tools] Failed to read annotation flag: %s: %s",
77
- type(exc).__name__, exc,
77
+ type(exc).__name__,
78
+ exc,
78
79
  )
79
80
  return False
80
81
  return False
@@ -204,7 +205,7 @@ class DynamicMcpTool(Tool[BaseModel, McpToolCallOutput]):
204
205
  def input_schema(self) -> type[BaseModel]:
205
206
  return self._input_model
206
207
 
207
- async def prompt(self, _safe_mode: bool = False) -> str:
208
+ async def prompt(self, _yolo_mode: bool = False) -> str:
208
209
  return await self.description()
209
210
 
210
211
  def is_read_only(self) -> bool:
@@ -321,7 +322,14 @@ class DynamicMcpTool(Tool[BaseModel, McpToolCallOutput]):
321
322
  data=annotated_output,
322
323
  result_for_assistant=final_text,
323
324
  )
324
- except (OSError, RuntimeError, ConnectionError, ValueError, KeyError, TypeError) as exc: # pragma: no cover - runtime errors
325
+ except (
326
+ OSError,
327
+ RuntimeError,
328
+ ConnectionError,
329
+ ValueError,
330
+ KeyError,
331
+ TypeError,
332
+ ) as exc: # pragma: no cover - runtime errors
325
333
  output = McpToolCallOutput(
326
334
  server=self.server_name,
327
335
  tool=self.tool_info.name,
@@ -333,7 +341,8 @@ class DynamicMcpTool(Tool[BaseModel, McpToolCallOutput]):
333
341
  )
334
342
  logger.warning(
335
343
  "Error calling MCP tool: %s: %s",
336
- type(exc).__name__, exc,
344
+ type(exc).__name__,
345
+ exc,
337
346
  extra={
338
347
  "server": self.server_name,
339
348
  "tool": self.tool_info.name,
@@ -381,10 +390,16 @@ def load_dynamic_mcp_tools_sync(project_path: Optional[Path] = None) -> List[Dyn
381
390
 
382
391
  try:
383
392
  return asyncio.run(_load_and_keep())
384
- except (OSError, RuntimeError, ConnectionError, ValueError) as exc: # pragma: no cover - SDK/runtime failures
393
+ except (
394
+ OSError,
395
+ RuntimeError,
396
+ ConnectionError,
397
+ ValueError,
398
+ ) as exc: # pragma: no cover - SDK/runtime failures
385
399
  logger.warning(
386
400
  "Failed to initialize MCP runtime for dynamic tools (sync): %s: %s",
387
- type(exc).__name__, exc,
401
+ type(exc).__name__,
402
+ exc,
388
403
  )
389
404
  return []
390
405
 
@@ -393,10 +408,16 @@ async def load_dynamic_mcp_tools_async(project_path: Optional[Path] = None) -> L
393
408
  """Async loader for MCP tools when already in an event loop."""
394
409
  try:
395
410
  runtime = await ensure_mcp_runtime(project_path)
396
- except (OSError, RuntimeError, ConnectionError, ValueError) as exc: # pragma: no cover - SDK/runtime failures
411
+ except (
412
+ OSError,
413
+ RuntimeError,
414
+ ConnectionError,
415
+ ValueError,
416
+ ) as exc: # pragma: no cover - SDK/runtime failures
397
417
  logger.warning(
398
418
  "Failed to initialize MCP runtime for dynamic tools (async): %s: %s",
399
- type(exc).__name__, exc,
419
+ type(exc).__name__,
420
+ exc,
400
421
  )
401
422
  return []
402
423
  return _build_dynamic_mcp_tools(runtime)
@@ -151,7 +151,7 @@ class EnterPlanModeTool(Tool[EnterPlanModeToolInput, EnterPlanModeToolOutput]):
151
151
  def input_schema(self) -> type[EnterPlanModeToolInput]:
152
152
  return EnterPlanModeToolInput
153
153
 
154
- async def prompt(self, safe_mode: bool = False) -> str: # noqa: ARG002
154
+ async def prompt(self, yolo_mode: bool = False) -> str: # noqa: ARG002
155
155
  return ENTER_PLAN_MODE_PROMPT
156
156
 
157
157
  def user_facing_name(self) -> str:
@@ -84,7 +84,7 @@ class ExitPlanModeTool(Tool[ExitPlanModeToolInput, ExitPlanModeToolOutput]):
84
84
  def input_schema(self) -> type[ExitPlanModeToolInput]:
85
85
  return ExitPlanModeToolInput
86
86
 
87
- async def prompt(self, safe_mode: bool = False) -> str: # noqa: ARG002
87
+ async def prompt(self, yolo_mode: bool = False) -> str: # noqa: ARG002
88
88
  return EXIT_PLAN_MODE_PROMPT
89
89
 
90
90
  def user_facing_name(self) -> str:
@@ -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
 
@@ -84,7 +119,7 @@ match exactly (including whitespace and indentation)."""
84
119
  ),
85
120
  ]
86
121
 
87
- async def prompt(self, safe_mode: bool = False) -> str:
122
+ async def prompt(self, yolo_mode: bool = False) -> str:
88
123
  return (
89
124
  "Performs exact string replacements in files.\n\n"
90
125
  "Usage:\n"
@@ -159,7 +194,9 @@ match exactly (including whitespace and indentation)."""
159
194
 
160
195
  # Check if path is ignored (warning for edit operations)
161
196
  file_path_obj = Path(file_path)
162
- should_proceed, warning_msg = check_path_for_tool(file_path_obj, tool_name="Edit", warn_only=True)
197
+ should_proceed, warning_msg = check_path_for_tool(
198
+ file_path_obj, tool_name="Edit", warn_only=True
199
+ )
163
200
  if warning_msg:
164
201
  logger.warning("[file_edit_tool] %s", warning_msg)
165
202
 
@@ -178,57 +215,131 @@ match exactly (including whitespace and indentation)."""
178
215
  async def call(
179
216
  self, input_data: FileEditToolInput, context: ToolUseContext
180
217
  ) -> AsyncGenerator[ToolOutput, None]:
181
- """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)
182
223
 
183
224
  try:
184
- # Read the file
185
- with open(input_data.file_path, "r", encoding="utf-8") as f:
186
- content = f.read()
187
-
188
- # Check if old_string exists
189
- if input_data.old_string not in content:
190
- output = FileEditToolOutput(
191
- file_path=input_data.file_path,
192
- replacements_made=0,
193
- success=False,
194
- message=f"String not found in file: {input_data.file_path}",
195
- )
196
- yield ToolResult(
197
- data=output, result_for_assistant=self.render_result_for_assistant(output)
198
- )
199
- return
200
-
201
- # Count occurrences
202
- occurrence_count = content.count(input_data.old_string)
203
-
204
- # Check for ambiguity if not replace_all
205
- if not input_data.replace_all and occurrence_count > 1:
206
- output = FileEditToolOutput(
207
- file_path=input_data.file_path,
208
- replacements_made=0,
209
- success=False,
210
- message=f"String appears {occurrence_count} times in file. "
211
- f"Either provide a unique string or use replace_all=true",
212
- )
213
- yield ToolResult(
214
- data=output, result_for_assistant=self.render_result_for_assistant(output)
215
- )
216
- return
217
-
218
- # Perform replacement
219
- if input_data.replace_all:
220
- new_content = content.replace(input_data.old_string, input_data.new_string)
221
- replacements = occurrence_count
222
- else:
223
- new_content = content.replace(input_data.old_string, input_data.new_string, 1)
224
- replacements = 1
225
-
226
- # Write the file
227
- with open(input_data.file_path, "w", encoding="utf-8") as f:
228
- f.write(new_content)
229
-
230
- # Use absolute path to ensure consistency with validation lookup
231
- 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
232
343
  try:
233
344
  record_snapshot(
234
345
  abs_file_path,
@@ -238,7 +349,8 @@ match exactly (including whitespace and indentation)."""
238
349
  except (OSError, IOError, RuntimeError) as exc:
239
350
  logger.warning(
240
351
  "[file_edit_tool] Failed to record file snapshot: %s: %s",
241
- type(exc).__name__, exc,
352
+ type(exc).__name__,
353
+ exc,
242
354
  extra={"file_path": abs_file_path},
243
355
  )
244
356
 
@@ -330,7 +442,8 @@ match exactly (including whitespace and indentation)."""
330
442
  except (OSError, IOError, PermissionError, UnicodeDecodeError, ValueError) as e:
331
443
  logger.warning(
332
444
  "[file_edit_tool] Error editing file: %s: %s",
333
- type(e).__name__, e,
445
+ type(e).__name__,
446
+ e,
334
447
  extra={"file_path": input_data.file_path},
335
448
  )
336
449
  error_output = FileEditToolOutput(
@@ -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."""
@@ -70,7 +74,7 @@ and limit to read only a portion of the file."""
70
74
  ),
71
75
  ]
72
76
 
73
- async def prompt(self, safe_mode: bool = False) -> str:
77
+ async def prompt(self, yolo_mode: bool = False) -> str:
74
78
  return (
75
79
  "Read a file from the local filesystem.\n\n"
76
80
  "Usage:\n"
@@ -106,7 +110,9 @@ and limit to read only a portion of the file."""
106
110
 
107
111
  # Check if path is ignored (warning only for read operations)
108
112
  file_path = Path(input_data.file_path)
109
- should_proceed, warning_msg = check_path_for_tool(file_path, tool_name="Read", warn_only=True)
113
+ should_proceed, warning_msg = check_path_for_tool(
114
+ file_path, tool_name="Read", warn_only=True
115
+ )
110
116
  if warning_msg:
111
117
  logger.info("[file_read_tool] %s", warning_msg)
112
118
 
@@ -138,6 +144,22 @@ and limit to read only a portion of the file."""
138
144
  """Read the file."""
139
145
 
140
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
+
141
163
  with open(input_data.file_path, "r", encoding="utf-8", errors="replace") as f:
142
164
  lines = f.readlines()
143
165
 
@@ -166,7 +188,8 @@ and limit to read only a portion of the file."""
166
188
  except (OSError, IOError, RuntimeError) as exc:
167
189
  logger.warning(
168
190
  "[file_read_tool] Failed to record file snapshot: %s: %s",
169
- type(exc).__name__, exc,
191
+ type(exc).__name__,
192
+ exc,
170
193
  extra={"file_path": input_data.file_path},
171
194
  )
172
195
 
@@ -185,7 +208,8 @@ and limit to read only a portion of the file."""
185
208
  except (OSError, IOError, UnicodeDecodeError, ValueError) as e:
186
209
  logger.warning(
187
210
  "[file_read_tool] Error reading file: %s: %s",
188
- type(e).__name__, e,
211
+ type(e).__name__,
212
+ e,
189
213
  extra={"file_path": input_data.file_path},
190
214
  )
191
215
  # Create an error output