ripperdoc 0.2.9__py3-none-any.whl → 0.3.0__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/cli/cli.py +379 -51
  3. ripperdoc/cli/commands/__init__.py +6 -0
  4. ripperdoc/cli/commands/agents_cmd.py +128 -5
  5. ripperdoc/cli/commands/clear_cmd.py +8 -0
  6. ripperdoc/cli/commands/doctor_cmd.py +29 -0
  7. ripperdoc/cli/commands/exit_cmd.py +1 -0
  8. ripperdoc/cli/commands/memory_cmd.py +2 -1
  9. ripperdoc/cli/commands/models_cmd.py +63 -7
  10. ripperdoc/cli/commands/resume_cmd.py +5 -0
  11. ripperdoc/cli/commands/skills_cmd.py +103 -0
  12. ripperdoc/cli/commands/stats_cmd.py +244 -0
  13. ripperdoc/cli/commands/status_cmd.py +10 -0
  14. ripperdoc/cli/commands/tasks_cmd.py +6 -3
  15. ripperdoc/cli/commands/themes_cmd.py +139 -0
  16. ripperdoc/cli/ui/file_mention_completer.py +63 -13
  17. ripperdoc/cli/ui/helpers.py +6 -3
  18. ripperdoc/cli/ui/interrupt_handler.py +34 -0
  19. ripperdoc/cli/ui/panels.py +14 -8
  20. ripperdoc/cli/ui/rich_ui.py +737 -47
  21. ripperdoc/cli/ui/spinner.py +93 -18
  22. ripperdoc/cli/ui/thinking_spinner.py +1 -2
  23. ripperdoc/cli/ui/tool_renderers.py +10 -9
  24. ripperdoc/cli/ui/wizard.py +24 -19
  25. ripperdoc/core/agents.py +14 -3
  26. ripperdoc/core/config.py +238 -6
  27. ripperdoc/core/default_tools.py +91 -10
  28. ripperdoc/core/hooks/events.py +4 -0
  29. ripperdoc/core/hooks/llm_callback.py +58 -0
  30. ripperdoc/core/hooks/manager.py +6 -0
  31. ripperdoc/core/permissions.py +160 -9
  32. ripperdoc/core/providers/openai.py +84 -28
  33. ripperdoc/core/query.py +489 -87
  34. ripperdoc/core/query_utils.py +17 -14
  35. ripperdoc/core/skills.py +1 -0
  36. ripperdoc/core/theme.py +298 -0
  37. ripperdoc/core/tool.py +15 -5
  38. ripperdoc/protocol/__init__.py +14 -0
  39. ripperdoc/protocol/models.py +300 -0
  40. ripperdoc/protocol/stdio.py +1453 -0
  41. ripperdoc/tools/background_shell.py +354 -139
  42. ripperdoc/tools/bash_tool.py +117 -22
  43. ripperdoc/tools/file_edit_tool.py +228 -50
  44. ripperdoc/tools/file_read_tool.py +154 -3
  45. ripperdoc/tools/file_write_tool.py +53 -11
  46. ripperdoc/tools/grep_tool.py +98 -8
  47. ripperdoc/tools/lsp_tool.py +609 -0
  48. ripperdoc/tools/multi_edit_tool.py +26 -3
  49. ripperdoc/tools/skill_tool.py +52 -1
  50. ripperdoc/tools/task_tool.py +539 -65
  51. ripperdoc/utils/conversation_compaction.py +1 -1
  52. ripperdoc/utils/file_watch.py +216 -7
  53. ripperdoc/utils/image_utils.py +125 -0
  54. ripperdoc/utils/log.py +30 -3
  55. ripperdoc/utils/lsp.py +812 -0
  56. ripperdoc/utils/mcp.py +80 -18
  57. ripperdoc/utils/message_formatting.py +7 -4
  58. ripperdoc/utils/messages.py +198 -33
  59. ripperdoc/utils/pending_messages.py +50 -0
  60. ripperdoc/utils/permissions/shell_command_validation.py +3 -3
  61. ripperdoc/utils/permissions/tool_permission_utils.py +180 -15
  62. ripperdoc/utils/platform.py +198 -0
  63. ripperdoc/utils/session_heatmap.py +242 -0
  64. ripperdoc/utils/session_history.py +2 -2
  65. ripperdoc/utils/session_stats.py +294 -0
  66. ripperdoc/utils/shell_utils.py +8 -5
  67. ripperdoc/utils/todo.py +0 -6
  68. {ripperdoc-0.2.9.dist-info → ripperdoc-0.3.0.dist-info}/METADATA +55 -17
  69. ripperdoc-0.3.0.dist-info/RECORD +136 -0
  70. {ripperdoc-0.2.9.dist-info → ripperdoc-0.3.0.dist-info}/WHEEL +1 -1
  71. ripperdoc/sdk/__init__.py +0 -9
  72. ripperdoc/sdk/client.py +0 -333
  73. ripperdoc-0.2.9.dist-info/RECORD +0 -123
  74. {ripperdoc-0.2.9.dist-info → ripperdoc-0.3.0.dist-info}/entry_points.txt +0 -0
  75. {ripperdoc-0.2.9.dist-info → ripperdoc-0.3.0.dist-info}/licenses/LICENSE +0 -0
  76. {ripperdoc-0.2.9.dist-info → ripperdoc-0.3.0.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,
@@ -51,6 +50,7 @@ from ripperdoc.utils.sandbox_utils import create_sandbox_wrapper, is_sandbox_ava
51
50
  from ripperdoc.utils.safe_get_cwd import get_original_cwd, safe_get_cwd
52
51
  from ripperdoc.utils.shell_utils import build_shell_command, find_suitable_shell
53
52
  from ripperdoc.utils.log import get_logger
53
+ from ripperdoc.utils.platform import IS_WINDOWS
54
54
 
55
55
  logger = get_logger()
56
56
 
@@ -341,9 +341,8 @@ build projects, run tests, and interact with the file system."""
341
341
  allow_rules,
342
342
  deny_rules,
343
343
  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),
344
+ # danger_detector uses default: validate_shell_command(cmd).behavior != "passthrough"
345
+ # read_only_detector uses default: _is_command_read_only
347
346
  )
348
347
 
349
348
  # Background executions need an explicit confirmation even if heuristics
@@ -384,6 +383,43 @@ build projects, run tests, and interact with the file system."""
384
383
  result=False, message="Sandbox mode requested but not available."
385
384
  )
386
385
 
386
+ # Validate shell_executable if provided
387
+ if input_data.shell_executable:
388
+ shell_path = Path(input_data.shell_executable)
389
+ # Must be an absolute path
390
+ if not shell_path.is_absolute():
391
+ return ValidationResult(
392
+ result=False,
393
+ message=f"shell_executable must be an absolute path: {input_data.shell_executable}",
394
+ )
395
+ # Must exist and be a file
396
+ if not shell_path.exists():
397
+ return ValidationResult(
398
+ result=False,
399
+ message=f"shell_executable not found: {input_data.shell_executable}",
400
+ )
401
+ if not shell_path.is_file():
402
+ return ValidationResult(
403
+ result=False,
404
+ message=f"shell_executable is not a file: {input_data.shell_executable}",
405
+ )
406
+ # Must be executable
407
+ if not os.access(shell_path, os.X_OK):
408
+ return ValidationResult(
409
+ result=False,
410
+ message=f"shell_executable is not executable: {input_data.shell_executable}",
411
+ )
412
+ # Must be in a safe system directory or match known shell patterns
413
+ safe_dirs = {"/bin", "/usr/bin", "/usr/local/bin", "/opt/homebrew/bin"}
414
+ shell_name = shell_path.name.lower()
415
+ known_shells = {"bash", "sh", "zsh", "fish", "dash", "ksh", "tcsh", "csh"}
416
+ parent_dir = str(shell_path.parent)
417
+ if parent_dir not in safe_dirs and shell_name not in known_shells:
418
+ return ValidationResult(
419
+ result=False,
420
+ message=f"shell_executable must be a known shell in a standard location: {input_data.shell_executable}",
421
+ )
422
+
387
423
  # Note: Path validation for sensitive directories (cd/find to /usr, /etc, etc.)
388
424
  # is now handled in check_permissions() to allow user confirmation for read-only ops.
389
425
 
@@ -396,15 +432,9 @@ build projects, run tests, and interact with the file system."""
396
432
  result=False, message="This command cannot be run in background"
397
433
  )
398
434
 
399
- validation = validate_shell_command(input_data.command)
400
- if validation.behavior == "ask":
401
- # In yolo mode, allow shell metacharacters
402
- if context and hasattr(context, 'yolo_mode') and context.yolo_mode \
403
- and "shell metacharacters" in validation.message:
404
- # Allow commands with shell metacharacters in yolo mode
405
- return ValidationResult(result=True)
406
- return ValidationResult(result=False, message=validation.message)
407
-
435
+ # Note: Security validation (shell metacharacters, destructive commands, etc.)
436
+ # is handled in check_permissions() via evaluate_shell_command_permissions().
437
+ # validate_input() should only perform format/parameter validation.
408
438
  return ValidationResult(result=True)
409
439
 
410
440
  def render_result_for_assistant(self, output: BashToolOutput) -> str:
@@ -568,6 +598,7 @@ build projects, run tests, and interact with the file system."""
568
598
  sandbox_requested: bool,
569
599
  start_time: float,
570
600
  input_data: BashToolInput,
601
+ context: Optional[ToolUseContext] = None,
571
602
  ) -> Optional[BashToolOutput]:
572
603
  """Run a command in background mode.
573
604
 
@@ -593,8 +624,50 @@ build projects, run tests, and interact with the file system."""
593
624
  if input_data.timeout is None
594
625
  else (timeout_seconds if timeout_seconds > 0 else None)
595
626
  )
627
+
628
+ completion_callbacks = None
629
+ queue = getattr(context, "pending_message_queue", None) if context else None
630
+ if queue is not None:
631
+
632
+ def _notify_completion(task: Any) -> None:
633
+ # Mirror status computation from background_shell._compute_status
634
+ if getattr(task, "killed", False):
635
+ status = "killed"
636
+ elif getattr(task, "timed_out", False):
637
+ status = "failed"
638
+ else:
639
+ exit_code = getattr(task, "exit_code", None)
640
+ status = (
641
+ "running"
642
+ if exit_code is None
643
+ else ("completed" if exit_code == 0 else "failed")
644
+ )
645
+ exit_code = getattr(task, "exit_code", None)
646
+ status_line = (
647
+ f"Background bash task {getattr(task, 'id', '')} finished with status: {status}"
648
+ )
649
+ if exit_code is not None:
650
+ status_line += f" (exit code {exit_code})"
651
+ details = [
652
+ status_line,
653
+ f"Command: {effective_command}",
654
+ "Use BashOutput with this task id to read stdout/stderr and continue.",
655
+ ]
656
+ queue.enqueue_text(
657
+ "\n".join(details),
658
+ metadata={
659
+ "source": "background_bash",
660
+ "background_task_id": getattr(task, "id", None),
661
+ },
662
+ )
663
+
664
+ completion_callbacks = [_notify_completion] # type: ignore[assignment]
665
+
596
666
  task_id = await start_background_command(
597
- final_command, timeout=bg_timeout, shell_executable=resolved_shell
667
+ final_command,
668
+ timeout=bg_timeout,
669
+ shell_executable=resolved_shell,
670
+ completion_callbacks=completion_callbacks, # type: ignore[arg-type]
598
671
  )
599
672
 
600
673
  return BashToolOutput(
@@ -860,6 +933,7 @@ build projects, run tests, and interact with the file system."""
860
933
  sandbox_requested,
861
934
  start,
862
935
  input_data,
936
+ context,
863
937
  )
864
938
  if output:
865
939
  yield ToolResult(
@@ -992,35 +1066,56 @@ build projects, run tests, and interact with the file system."""
992
1066
  finally:
993
1067
  self._current_is_read_only = previous_read_only
994
1068
  if sandbox_cleanup:
995
- with contextlib.suppress(Exception):
1069
+ with contextlib.suppress(OSError, IOError, PermissionError):
996
1070
  sandbox_cleanup()
997
1071
 
998
1072
  async def _force_kill_process(
999
1073
  self, process: asyncio.subprocess.Process, grace_seconds: float = KILL_GRACE_SECONDS
1000
1074
  ) -> None:
1001
- """Attempt to terminate a process group and avoid hanging waits."""
1075
+ """Attempt to terminate a process group and avoid hanging waits.
1076
+
1077
+ Platform differences:
1078
+ - Unix: Uses killpg with SIGTERM/SIGKILL to terminate process groups
1079
+ - Windows: Uses process.terminate()/kill() which sends appropriate signals
1080
+ """
1002
1081
  if process.returncode is not None:
1003
1082
  return
1004
1083
 
1005
1084
  def _terminate() -> None:
1006
- if hasattr(os, "killpg"):
1007
- os.killpg(process.pid, signal.SIGTERM)
1085
+ if IS_WINDOWS:
1086
+ # Windows: use process.terminate() which is cross-platform
1087
+ process.terminate()
1088
+ elif hasattr(os, "killpg"):
1089
+ # Unix: terminate the entire process group
1090
+ try:
1091
+ os.killpg(process.pid, signal.SIGTERM)
1092
+ except (ProcessLookupError, PermissionError, OSError):
1093
+ # Fallback to single process termination
1094
+ process.terminate()
1008
1095
  else:
1009
1096
  process.terminate()
1010
1097
 
1011
1098
  def _kill() -> None:
1012
- if hasattr(os, "killpg"):
1013
- os.killpg(process.pid, signal.SIGKILL)
1099
+ if IS_WINDOWS:
1100
+ # Windows: use process.kill() which forcefully terminates
1101
+ process.kill()
1102
+ elif hasattr(os, "killpg") and hasattr(signal, "SIGKILL"):
1103
+ # Unix: forcefully kill the entire process group
1104
+ try:
1105
+ os.killpg(process.pid, signal.SIGKILL)
1106
+ except (ProcessLookupError, PermissionError, OSError):
1107
+ # Fallback to single process kill
1108
+ process.kill()
1014
1109
  else:
1015
1110
  process.kill()
1016
1111
 
1017
- with contextlib.suppress(ProcessLookupError, PermissionError):
1112
+ with contextlib.suppress(ProcessLookupError, PermissionError, OSError):
1018
1113
  _terminate()
1019
1114
  with contextlib.suppress(asyncio.TimeoutError):
1020
1115
  await asyncio.wait_for(process.wait(), timeout=grace_seconds)
1021
1116
  return
1022
1117
 
1023
- with contextlib.suppress(ProcessLookupError, PermissionError):
1118
+ with contextlib.suppress(ProcessLookupError, PermissionError, OSError):
1024
1119
  _kill()
1025
1120
  with contextlib.suppress(asyncio.TimeoutError):
1026
1121
  await asyncio.wait_for(process.wait(), timeout=grace_seconds)
@@ -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 (
@@ -17,12 +19,65 @@ from ripperdoc.core.tool import (
17
19
  ValidationResult,
18
20
  )
19
21
  from ripperdoc.utils.log import get_logger
22
+ from ripperdoc.utils.platform import HAS_FCNTL
20
23
  from ripperdoc.utils.file_watch import record_snapshot
21
24
  from ripperdoc.utils.path_ignore import check_path_for_tool
25
+ from ripperdoc.tools.file_read_tool import detect_file_encoding
22
26
 
23
27
  logger = get_logger()
24
28
 
25
29
 
30
+ def determine_edit_encoding(file_path: str, new_content: str) -> str:
31
+ """Determine encoding for editing a file.
32
+
33
+ Detects the file's current encoding and verifies the new content
34
+ can be encoded with it. Falls back to UTF-8 if needed.
35
+ """
36
+ detected_encoding, _ = detect_file_encoding(file_path)
37
+
38
+ if not detected_encoding:
39
+ return "utf-8"
40
+
41
+ # Verify new content can be encoded
42
+ try:
43
+ new_content.encode(detected_encoding)
44
+ return detected_encoding
45
+ except (UnicodeEncodeError, LookupError):
46
+ logger.info(
47
+ "New content cannot be encoded with %s, falling back to UTF-8 for %s",
48
+ detected_encoding,
49
+ file_path,
50
+ )
51
+ return "utf-8"
52
+
53
+
54
+ @contextlib.contextmanager
55
+ def _file_lock(file_handle: TextIO, exclusive: bool = True) -> Generator[None, None, None]:
56
+ """Acquire a file lock, with fallback for systems without fcntl.
57
+
58
+ Args:
59
+ file_handle: An open file handle to lock
60
+ exclusive: If True, acquire exclusive lock; otherwise shared lock
61
+
62
+ Yields:
63
+ None
64
+ """
65
+ if not HAS_FCNTL:
66
+ # On Windows or systems without fcntl, skip locking
67
+ yield
68
+ return
69
+
70
+ import fcntl
71
+
72
+ lock_type = fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH
73
+ try:
74
+ fcntl.flock(file_handle.fileno(), lock_type)
75
+ yield
76
+ finally:
77
+ with contextlib.suppress(OSError):
78
+ fcntl.flock(file_handle.fileno(), fcntl.LOCK_UN)
79
+
80
+
26
81
  class FileEditToolInput(BaseModel):
27
82
  """Input schema for FileEditTool."""
28
83
 
@@ -180,62 +235,185 @@ match exactly (including whitespace and indentation)."""
180
235
  async def call(
181
236
  self, input_data: FileEditToolInput, context: ToolUseContext
182
237
  ) -> AsyncGenerator[ToolOutput, None]:
183
- """Edit the file."""
238
+ """Edit the file with TOCTOU protection."""
239
+
240
+ abs_file_path = os.path.abspath(input_data.file_path)
241
+ file_state_cache = getattr(context, "file_state_cache", {})
242
+ file_snapshot = file_state_cache.get(abs_file_path)
243
+
244
+ # Detect file encoding before opening
245
+ file_encoding, _ = detect_file_encoding(abs_file_path)
246
+ if not file_encoding:
247
+ file_encoding = "utf-8"
184
248
 
185
249
  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)
250
+ # Open file with exclusive lock to prevent concurrent modifications
251
+ # Use r+ mode to get a file handle we can lock before reading
252
+ #
253
+ # TOCTOU mitigation strategy:
254
+ # 1. Record mtime immediately after open (pre_lock_mtime)
255
+ # 2. Acquire exclusive lock
256
+ # 3. Check mtime again after lock (post_lock_mtime)
257
+ # 4. If pre != post, file was modified in the window between open and lock
258
+ # 5. Also validate against cached snapshot timestamp
259
+ with open(abs_file_path, "r+", encoding=file_encoding) as f:
260
+ # Record mtime immediately after open, before acquiring lock
261
+ try:
262
+ pre_lock_mtime = os.fstat(f.fileno()).st_mtime
263
+ except OSError:
264
+ pre_lock_mtime = None
265
+
266
+ with _file_lock(f, exclusive=True):
267
+ # Check mtime after acquiring lock to detect modifications
268
+ # during the window between open() and lock acquisition
269
+ try:
270
+ post_lock_mtime = os.fstat(f.fileno()).st_mtime
271
+ except OSError:
272
+ post_lock_mtime = None
273
+
274
+ # Detect modification during open->lock window
275
+ if pre_lock_mtime is not None and post_lock_mtime is not None:
276
+ if post_lock_mtime > pre_lock_mtime:
277
+ output = FileEditToolOutput(
278
+ file_path=input_data.file_path,
279
+ replacements_made=0,
280
+ success=False,
281
+ message="File was modified while acquiring lock. Please retry.",
282
+ )
283
+ yield ToolResult(
284
+ data=output,
285
+ result_for_assistant=self.render_result_for_assistant(output),
286
+ )
287
+ return
288
+
289
+ # Validate against cached snapshot timestamp
290
+ if file_snapshot and post_lock_mtime is not None:
291
+ if post_lock_mtime > file_snapshot.timestamp:
292
+ output = FileEditToolOutput(
293
+ file_path=input_data.file_path,
294
+ replacements_made=0,
295
+ success=False,
296
+ message="File has been modified since read, either by the user "
297
+ "or by a linter. Read it again before attempting to edit it.",
298
+ )
299
+ yield ToolResult(
300
+ data=output,
301
+ result_for_assistant=self.render_result_for_assistant(output),
302
+ )
303
+ return
304
+
305
+ # Read content while holding the lock
306
+ content = f.read()
307
+
308
+ # Check if old_string exists
309
+ if input_data.old_string not in content:
310
+ output = FileEditToolOutput(
311
+ file_path=input_data.file_path,
312
+ replacements_made=0,
313
+ success=False,
314
+ message=f"String not found in file: {input_data.file_path}",
315
+ )
316
+ yield ToolResult(
317
+ data=output,
318
+ result_for_assistant=self.render_result_for_assistant(output),
319
+ )
320
+ return
321
+
322
+ # Count occurrences
323
+ occurrence_count = content.count(input_data.old_string)
324
+
325
+ # Check for ambiguity if not replace_all
326
+ if not input_data.replace_all and occurrence_count > 1:
327
+ output = FileEditToolOutput(
328
+ file_path=input_data.file_path,
329
+ replacements_made=0,
330
+ success=False,
331
+ message=f"String appears {occurrence_count} times in file. "
332
+ f"Either provide a unique string or use replace_all=true",
333
+ )
334
+ yield ToolResult(
335
+ data=output,
336
+ result_for_assistant=self.render_result_for_assistant(output),
337
+ )
338
+ return
339
+
340
+ # Perform replacement
341
+ if input_data.replace_all:
342
+ new_content = content.replace(input_data.old_string, input_data.new_string)
343
+ replacements = occurrence_count
344
+ else:
345
+ new_content = content.replace(
346
+ input_data.old_string, input_data.new_string, 1
347
+ )
348
+ replacements = 1
349
+
350
+ # Verify new content can be encoded with file's encoding
351
+ # If not, fall back to UTF-8
352
+ write_encoding = file_encoding
353
+ try:
354
+ new_content.encode(file_encoding)
355
+ except (UnicodeEncodeError, LookupError):
356
+ logger.info(
357
+ "New content cannot be encoded with %s, using UTF-8 for %s",
358
+ file_encoding,
359
+ abs_file_path,
360
+ )
361
+ write_encoding = "utf-8"
362
+
363
+ # Atomic write: write to temp file then rename
364
+ # This ensures the file is either fully written or not at all
365
+ file_dir = os.path.dirname(abs_file_path)
366
+ try:
367
+ # Create temp file in same directory to ensure same filesystem
368
+ fd, temp_path = tempfile.mkstemp(
369
+ dir=file_dir, prefix=".ripperdoc_edit_", suffix=".tmp"
370
+ )
371
+ try:
372
+ with os.fdopen(fd, "w", encoding=write_encoding) as temp_f:
373
+ temp_f.write(new_content)
374
+ # Preserve original file permissions
375
+ original_stat = os.fstat(f.fileno())
376
+ os.chmod(temp_path, original_stat.st_mode)
377
+ # Atomic replace (works on Unix, best-effort on Windows)
378
+ os.replace(temp_path, abs_file_path)
379
+ except Exception:
380
+ # Clean up temp file on failure
381
+ with contextlib.suppress(OSError):
382
+ os.unlink(temp_path)
383
+ raise
384
+ except OSError as atomic_error:
385
+ # Fallback to in-place write if atomic write fails
386
+ # (e.g., cross-filesystem issues)
387
+ # Re-verify file hasn't changed before fallback write (TOCTOU protection)
388
+ f.seek(0)
389
+ current_content = f.read()
390
+ if current_content != content:
391
+ output = FileEditToolOutput(
392
+ file_path=input_data.file_path,
393
+ replacements_made=0,
394
+ success=False,
395
+ message="File was modified during atomic write fallback. Please retry.",
396
+ )
397
+ yield ToolResult(
398
+ data=output,
399
+ result_for_assistant=self.render_result_for_assistant(output),
400
+ )
401
+ return
402
+ f.seek(0)
403
+ f.truncate()
404
+ f.write(new_content)
405
+ logger.debug(
406
+ "[file_edit_tool] Atomic write failed, used fallback: %s",
407
+ atomic_error,
408
+ )
409
+
410
+ # Record the new snapshot after successful edit
234
411
  try:
235
412
  record_snapshot(
236
413
  abs_file_path,
237
414
  new_content,
238
415
  getattr(context, "file_state_cache", {}),
416
+ encoding=write_encoding,
239
417
  )
240
418
  except (OSError, IOError, RuntimeError) as exc:
241
419
  logger.warning(