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.
- ripperdoc/__init__.py +1 -1
- ripperdoc/cli/cli.py +257 -123
- ripperdoc/cli/commands/__init__.py +2 -1
- ripperdoc/cli/commands/agents_cmd.py +138 -8
- ripperdoc/cli/commands/clear_cmd.py +9 -4
- ripperdoc/cli/commands/config_cmd.py +1 -1
- ripperdoc/cli/commands/context_cmd.py +3 -2
- ripperdoc/cli/commands/doctor_cmd.py +18 -4
- ripperdoc/cli/commands/exit_cmd.py +1 -0
- ripperdoc/cli/commands/hooks_cmd.py +27 -53
- ripperdoc/cli/commands/models_cmd.py +27 -10
- ripperdoc/cli/commands/permissions_cmd.py +27 -9
- ripperdoc/cli/commands/resume_cmd.py +9 -3
- ripperdoc/cli/commands/stats_cmd.py +244 -0
- ripperdoc/cli/commands/status_cmd.py +4 -4
- ripperdoc/cli/commands/tasks_cmd.py +8 -4
- ripperdoc/cli/ui/file_mention_completer.py +2 -1
- ripperdoc/cli/ui/interrupt_handler.py +2 -3
- ripperdoc/cli/ui/message_display.py +4 -2
- ripperdoc/cli/ui/panels.py +1 -0
- ripperdoc/cli/ui/provider_options.py +247 -0
- ripperdoc/cli/ui/rich_ui.py +403 -81
- ripperdoc/cli/ui/spinner.py +54 -18
- ripperdoc/cli/ui/thinking_spinner.py +1 -2
- ripperdoc/cli/ui/tool_renderers.py +8 -2
- ripperdoc/cli/ui/wizard.py +213 -0
- ripperdoc/core/agents.py +19 -6
- ripperdoc/core/config.py +51 -17
- ripperdoc/core/custom_commands.py +7 -6
- ripperdoc/core/default_tools.py +101 -12
- ripperdoc/core/hooks/config.py +1 -3
- ripperdoc/core/hooks/events.py +27 -28
- ripperdoc/core/hooks/executor.py +4 -6
- ripperdoc/core/hooks/integration.py +12 -21
- ripperdoc/core/hooks/llm_callback.py +59 -0
- ripperdoc/core/hooks/manager.py +40 -15
- ripperdoc/core/permissions.py +118 -12
- ripperdoc/core/providers/anthropic.py +109 -36
- ripperdoc/core/providers/gemini.py +70 -5
- ripperdoc/core/providers/openai.py +89 -24
- ripperdoc/core/query.py +273 -68
- ripperdoc/core/query_utils.py +2 -0
- ripperdoc/core/skills.py +9 -3
- ripperdoc/core/system_prompt.py +4 -2
- ripperdoc/core/tool.py +17 -8
- ripperdoc/sdk/client.py +79 -4
- ripperdoc/tools/ask_user_question_tool.py +5 -3
- ripperdoc/tools/background_shell.py +307 -135
- ripperdoc/tools/bash_output_tool.py +1 -1
- ripperdoc/tools/bash_tool.py +63 -24
- ripperdoc/tools/dynamic_mcp_tool.py +29 -8
- ripperdoc/tools/enter_plan_mode_tool.py +1 -1
- ripperdoc/tools/exit_plan_mode_tool.py +1 -1
- ripperdoc/tools/file_edit_tool.py +167 -54
- ripperdoc/tools/file_read_tool.py +28 -4
- ripperdoc/tools/file_write_tool.py +13 -10
- ripperdoc/tools/glob_tool.py +3 -2
- ripperdoc/tools/grep_tool.py +3 -2
- ripperdoc/tools/kill_bash_tool.py +1 -1
- ripperdoc/tools/ls_tool.py +1 -1
- ripperdoc/tools/lsp_tool.py +615 -0
- ripperdoc/tools/mcp_tools.py +13 -10
- ripperdoc/tools/multi_edit_tool.py +8 -7
- ripperdoc/tools/notebook_edit_tool.py +7 -4
- ripperdoc/tools/skill_tool.py +1 -1
- ripperdoc/tools/task_tool.py +519 -69
- ripperdoc/tools/todo_tool.py +2 -2
- ripperdoc/tools/tool_search_tool.py +3 -2
- ripperdoc/utils/conversation_compaction.py +9 -5
- ripperdoc/utils/file_watch.py +214 -5
- ripperdoc/utils/json_utils.py +2 -1
- ripperdoc/utils/lsp.py +806 -0
- ripperdoc/utils/mcp.py +11 -3
- ripperdoc/utils/memory.py +4 -2
- ripperdoc/utils/message_compaction.py +21 -7
- ripperdoc/utils/message_formatting.py +14 -7
- ripperdoc/utils/messages.py +126 -67
- ripperdoc/utils/path_ignore.py +35 -8
- ripperdoc/utils/permissions/path_validation_utils.py +2 -1
- ripperdoc/utils/permissions/shell_command_validation.py +427 -91
- ripperdoc/utils/permissions/tool_permission_utils.py +174 -15
- ripperdoc/utils/safe_get_cwd.py +2 -1
- ripperdoc/utils/session_heatmap.py +244 -0
- ripperdoc/utils/session_history.py +13 -6
- ripperdoc/utils/session_stats.py +293 -0
- ripperdoc/utils/todo.py +2 -1
- ripperdoc/utils/token_estimation.py +6 -1
- {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.10.dist-info}/METADATA +8 -2
- ripperdoc-0.2.10.dist-info/RECORD +129 -0
- ripperdoc-0.2.8.dist-info/RECORD +0 -121
- {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.10.dist-info}/WHEEL +0 -0
- {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.10.dist-info}/entry_points.txt +0 -0
- {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.10.dist-info}/licenses/LICENSE +0 -0
- {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.10.dist-info}/top_level.txt +0 -0
ripperdoc/tools/bash_tool.py
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
345
|
-
|
|
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
|
|
400
|
-
|
|
401
|
-
|
|
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
|
|
535
|
-
|
|
536
|
-
|
|
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__,
|
|
580
|
+
type(exc).__name__,
|
|
581
|
+
exc,
|
|
545
582
|
extra={"command": command},
|
|
546
583
|
)
|
|
547
|
-
return
|
|
548
|
-
|
|
549
|
-
|
|
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__,
|
|
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__,
|
|
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__,
|
|
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,
|
|
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 (
|
|
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__,
|
|
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 (
|
|
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__,
|
|
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 (
|
|
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__,
|
|
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,
|
|
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,
|
|
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,
|
|
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(
|
|
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
|
-
#
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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__,
|
|
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__,
|
|
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,
|
|
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(
|
|
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__,
|
|
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__,
|
|
211
|
+
type(e).__name__,
|
|
212
|
+
e,
|
|
189
213
|
extra={"file_path": input_data.file_path},
|
|
190
214
|
)
|
|
191
215
|
# Create an error output
|