omnimancer-cli 0.1.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.
- omnimancer/__init__.py +42 -0
- omnimancer/__main__.py +9 -0
- omnimancer/cli/__init__.py +8 -0
- omnimancer/cli/agent_manager.py +826 -0
- omnimancer/cli/agent_wizard.py +944 -0
- omnimancer/cli/approval_formatter.py +734 -0
- omnimancer/cli/approval_integration.py +734 -0
- omnimancer/cli/approval_prompt.py +1286 -0
- omnimancer/cli/batch_approval_display.py +617 -0
- omnimancer/cli/batch_approval_filters.py +729 -0
- omnimancer/cli/batch_operation_monitor.py +775 -0
- omnimancer/cli/commands/performance_cmd.py +624 -0
- omnimancer/cli/commands.py +757 -0
- omnimancer/cli/completion_handler.py +281 -0
- omnimancer/cli/config_setup_wizard.py +783 -0
- omnimancer/cli/dynamic_commands.py +371 -0
- omnimancer/cli/handlers/__init__.py +18 -0
- omnimancer/cli/handlers/agent_handler.py +865 -0
- omnimancer/cli/handlers/agent_persona_handler.py +1048 -0
- omnimancer/cli/handlers/custom_agent_handler.py +352 -0
- omnimancer/cli/handlers/permissions_handler.py +1609 -0
- omnimancer/cli/interface.py +6695 -0
- omnimancer/cli/performance_dashboard.py +612 -0
- omnimancer/core/__init__.py +34 -0
- omnimancer/core/agent/__init__.py +256 -0
- omnimancer/core/agent/agent_config.py +715 -0
- omnimancer/core/agent/agent_switcher.py +668 -0
- omnimancer/core/agent/approval_context.py +460 -0
- omnimancer/core/agent/approval_dialog.py +902 -0
- omnimancer/core/agent/approval_interface.py +628 -0
- omnimancer/core/agent/approval_manager.py +760 -0
- omnimancer/core/agent/cli_integration.py +470 -0
- omnimancer/core/agent/config.py +845 -0
- omnimancer/core/agent/custom_agent_integration.py +654 -0
- omnimancer/core/agent/diff_renderer.py +706 -0
- omnimancer/core/agent/file_content_display.py +503 -0
- omnimancer/core/agent/file_modification_workflow.py +658 -0
- omnimancer/core/agent/file_system_manager.py +1790 -0
- omnimancer/core/agent/input_handler.py +614 -0
- omnimancer/core/agent/metrics_collector.py +771 -0
- omnimancer/core/agent/optimization_engine.py +729 -0
- omnimancer/core/agent/performance_monitor.py +717 -0
- omnimancer/core/agent/persona.py +870 -0
- omnimancer/core/agent/persona_validator.py +672 -0
- omnimancer/core/agent/program_executor.py +748 -0
- omnimancer/core/agent/proposed_changes_integration.py +663 -0
- omnimancer/core/agent/read_before_write_errors.py +492 -0
- omnimancer/core/agent/read_before_write_ui.py +647 -0
- omnimancer/core/agent/rich_renderer.py +763 -0
- omnimancer/core/agent/status_config.py +238 -0
- omnimancer/core/agent/status_core.py +446 -0
- omnimancer/core/agent/status_integrated_engine.py +540 -0
- omnimancer/core/agent/status_manager.py +878 -0
- omnimancer/core/agent/status_ui_components.py +535 -0
- omnimancer/core/agent/template_applicator.py +548 -0
- omnimancer/core/agent/terminal_status_listener.py +394 -0
- omnimancer/core/agent/token_tracker.py +679 -0
- omnimancer/core/agent/types.py +60 -0
- omnimancer/core/agent/web_client.py +745 -0
- omnimancer/core/agent/workflow_orchestrator.py +826 -0
- omnimancer/core/agent_engine.py +1625 -0
- omnimancer/core/agent_mode_manager.py +706 -0
- omnimancer/core/agent_progress_ui.py +545 -0
- omnimancer/core/chat_manager.py +141 -0
- omnimancer/core/config_manager.py +2199 -0
- omnimancer/core/config_migration.py +772 -0
- omnimancer/core/config_provider.py +1974 -0
- omnimancer/core/config_security_validator.py +393 -0
- omnimancer/core/config_validator.py +768 -0
- omnimancer/core/conversation_manager.py +352 -0
- omnimancer/core/engine.py +787 -0
- omnimancer/core/enhanced_input.py +523 -0
- omnimancer/core/env_loader.py +83 -0
- omnimancer/core/fallback_manager.py +568 -0
- omnimancer/core/health_monitor.py +462 -0
- omnimancer/core/history_manager.py +462 -0
- omnimancer/core/mcp_integration_layer.py +878 -0
- omnimancer/core/models.py +1889 -0
- omnimancer/core/provider_capabilities.py +403 -0
- omnimancer/core/provider_initializer.py +450 -0
- omnimancer/core/provider_registry.py +432 -0
- omnimancer/core/security/__init__.py +16 -0
- omnimancer/core/security/approval_workflow.py +406 -0
- omnimancer/core/security/audit_logger.py +511 -0
- omnimancer/core/security/permission_controller.py +511 -0
- omnimancer/core/security/sandbox_manager.py +379 -0
- omnimancer/core/security/security_manager.py +530 -0
- omnimancer/core/setup_wizard.py +345 -0
- omnimancer/core/setup_wizard_core.py +449 -0
- omnimancer/core/setup_wizard_guide_generator.py +376 -0
- omnimancer/core/setup_wizard_provider_setup.py +454 -0
- omnimancer/core/setup_wizard_ui.py +317 -0
- omnimancer/core/setup_wizard_validation.py +371 -0
- omnimancer/core/signal_handler.py +174 -0
- omnimancer/mcp/__init__.py +11 -0
- omnimancer/mcp/client.py +725 -0
- omnimancer/mcp/manager.py +649 -0
- omnimancer/providers/__init__.py +24 -0
- omnimancer/providers/azure.py +608 -0
- omnimancer/providers/base.py +262 -0
- omnimancer/providers/bedrock.py +711 -0
- omnimancer/providers/claude.py +426 -0
- omnimancer/providers/claude_code.py +406 -0
- omnimancer/providers/cohere.py +412 -0
- omnimancer/providers/factory.py +988 -0
- omnimancer/providers/gemini.py +708 -0
- omnimancer/providers/mistral.py +554 -0
- omnimancer/providers/ollama.py +510 -0
- omnimancer/providers/openai.py +362 -0
- omnimancer/providers/openrouter.py +709 -0
- omnimancer/providers/perplexity.py +476 -0
- omnimancer/providers/vertex.py +617 -0
- omnimancer/providers/xai.py +484 -0
- omnimancer/ui/__init__.py +6 -0
- omnimancer/ui/cancellation_handler.py +235 -0
- omnimancer/ui/progress_indicator.py +318 -0
- omnimancer/utils/__init__.py +22 -0
- omnimancer/utils/error_handler.py +589 -0
- omnimancer/utils/errors.py +667 -0
- omnimancer/utils/retry.py +238 -0
- omnimancer_cli-0.1.0.dist-info/METADATA +413 -0
- omnimancer_cli-0.1.0.dist-info/RECORD +126 -0
- omnimancer_cli-0.1.0.dist-info/WHEEL +5 -0
- omnimancer_cli-0.1.0.dist-info/entry_points.txt +4 -0
- omnimancer_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- omnimancer_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1790 @@
|
|
|
1
|
+
"""File system operations manager with security, backup, and atomic operations."""
|
|
2
|
+
|
|
3
|
+
import glob as glob_module
|
|
4
|
+
import hashlib
|
|
5
|
+
import logging
|
|
6
|
+
import mimetypes
|
|
7
|
+
import os
|
|
8
|
+
from contextlib import asynccontextmanager
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import (
|
|
12
|
+
Any,
|
|
13
|
+
Callable,
|
|
14
|
+
Dict,
|
|
15
|
+
List,
|
|
16
|
+
Optional,
|
|
17
|
+
Union,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
import aiofiles
|
|
21
|
+
import aiofiles.os
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
from ..security import SecurityManager
|
|
26
|
+
from ..security.permission_controller import PermissionOperation
|
|
27
|
+
from .approval_manager import EnhancedApprovalManager
|
|
28
|
+
from .read_before_write_errors import (
|
|
29
|
+
CallbackError,
|
|
30
|
+
ContentValidationError,
|
|
31
|
+
DiffGenerationError,
|
|
32
|
+
FileReadError,
|
|
33
|
+
FileWriteError,
|
|
34
|
+
ReadBeforeWriteErrorHandler,
|
|
35
|
+
)
|
|
36
|
+
from .types import Operation, OperationResult, OperationType
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class FileOperationError(Exception):
|
|
40
|
+
"""Custom exception for file operation errors."""
|
|
41
|
+
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class FileSystemManager:
|
|
46
|
+
"""Comprehensive file system operations with security, backup, and atomic operations."""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
security_manager: Optional[SecurityManager] = None,
|
|
51
|
+
approval_manager: Optional[EnhancedApprovalManager] = None,
|
|
52
|
+
backup_dir: Optional[str] = None,
|
|
53
|
+
max_file_size_mb: int = 100,
|
|
54
|
+
chunk_size: int = 8192,
|
|
55
|
+
require_approval: bool = True,
|
|
56
|
+
error_handler: Optional[ReadBeforeWriteErrorHandler] = None,
|
|
57
|
+
base_path: Optional[str] = None,
|
|
58
|
+
):
|
|
59
|
+
|
|
60
|
+
self.security = security_manager or SecurityManager()
|
|
61
|
+
self.approval_manager = approval_manager
|
|
62
|
+
self.max_file_size_mb = max_file_size_mb
|
|
63
|
+
self.chunk_size = chunk_size
|
|
64
|
+
self.require_approval = require_approval
|
|
65
|
+
|
|
66
|
+
# Set up base path for operations (for test compatibility)
|
|
67
|
+
self.base_path = Path(base_path) if base_path else Path.cwd()
|
|
68
|
+
|
|
69
|
+
# Set up read-before-write error handler
|
|
70
|
+
self.error_handler = error_handler or ReadBeforeWriteErrorHandler()
|
|
71
|
+
|
|
72
|
+
# Set up backup directory
|
|
73
|
+
if backup_dir:
|
|
74
|
+
self.backup_dir = Path(backup_dir)
|
|
75
|
+
else:
|
|
76
|
+
self.backup_dir = Path("/tmp/omnimancer_backups")
|
|
77
|
+
|
|
78
|
+
self.backup_dir.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
|
|
80
|
+
# Operation tracking
|
|
81
|
+
self.active_operations: Dict[str, Dict[str, Any]] = {}
|
|
82
|
+
|
|
83
|
+
async def _request_approval_if_needed(self, operation: Operation) -> bool:
|
|
84
|
+
"""Request approval for operation if approval manager is configured."""
|
|
85
|
+
if not self.require_approval or not self.approval_manager:
|
|
86
|
+
return True
|
|
87
|
+
|
|
88
|
+
# Request approval through the approval manager
|
|
89
|
+
return await self.approval_manager.request_single_approval(operation)
|
|
90
|
+
|
|
91
|
+
def _create_operation(
|
|
92
|
+
self,
|
|
93
|
+
operation_type: OperationType,
|
|
94
|
+
description: str,
|
|
95
|
+
data: Dict[str, Any],
|
|
96
|
+
reversible: bool = False,
|
|
97
|
+
) -> Operation:
|
|
98
|
+
"""Create an Operation object for approval workflow."""
|
|
99
|
+
return Operation(
|
|
100
|
+
type=operation_type,
|
|
101
|
+
description=description,
|
|
102
|
+
data=data,
|
|
103
|
+
requires_approval=self.require_approval,
|
|
104
|
+
reversible=reversible,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
async def check_file_exists(
|
|
108
|
+
self, path: Union[str, Path], follow_symlinks: bool = True
|
|
109
|
+
) -> Dict[str, Any]:
|
|
110
|
+
"""
|
|
111
|
+
Check if a file exists with comprehensive error handling and metadata.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
path: Path to check for existence
|
|
115
|
+
follow_symlinks: Whether to follow symbolic links (default: True)
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
Dict containing:
|
|
119
|
+
- exists: Boolean indicating if file exists
|
|
120
|
+
- path: Resolved path string
|
|
121
|
+
- is_file: Boolean indicating if path points to a file
|
|
122
|
+
- is_directory: Boolean indicating if path points to a directory
|
|
123
|
+
- is_symlink: Boolean indicating if path is a symbolic link
|
|
124
|
+
- size: File size in bytes (if exists and is file)
|
|
125
|
+
- modified_time: Last modified timestamp (if exists)
|
|
126
|
+
- error: Error message if check failed
|
|
127
|
+
"""
|
|
128
|
+
try:
|
|
129
|
+
original_path = Path(path)
|
|
130
|
+
# Use resolved path for file operations, but preserve original path for return value
|
|
131
|
+
resolved_path = (
|
|
132
|
+
original_path.resolve() if follow_symlinks else original_path
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
# Check if original path is a symlink before resolving
|
|
136
|
+
is_symlink = await aiofiles.os.path.islink(original_path)
|
|
137
|
+
|
|
138
|
+
# First check existence using aiofiles for async operation
|
|
139
|
+
exists = await aiofiles.os.path.exists(resolved_path)
|
|
140
|
+
|
|
141
|
+
if not exists:
|
|
142
|
+
return {
|
|
143
|
+
"exists": False,
|
|
144
|
+
"path": str(original_path), # Return original path, not resolved
|
|
145
|
+
"is_file": False,
|
|
146
|
+
"is_directory": False,
|
|
147
|
+
"is_symlink": is_symlink, # May be True for broken symlinks
|
|
148
|
+
"size": None,
|
|
149
|
+
"modified_time": None,
|
|
150
|
+
"error": None,
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
# Since path exists, do security validation for access
|
|
154
|
+
# Note: We only do security check for actual access control,
|
|
155
|
+
# not for existence determination
|
|
156
|
+
try:
|
|
157
|
+
security_result = await self.security.secure_file_access(
|
|
158
|
+
str(resolved_path), "read"
|
|
159
|
+
)
|
|
160
|
+
if not security_result["success"]:
|
|
161
|
+
# Check if it's a real security denial vs. SecurityManager limitations
|
|
162
|
+
error_msg = security_result.get("error", "")
|
|
163
|
+
# If SecurityManager says "does not exist" but we know it exists,
|
|
164
|
+
# it might be a directory or other filesystem object the SecurityManager doesn't handle
|
|
165
|
+
if (
|
|
166
|
+
"does not exist" in error_msg.lower()
|
|
167
|
+
or "not found" in error_msg.lower()
|
|
168
|
+
):
|
|
169
|
+
# Continue with existence check - SecurityManager may not handle directories properly
|
|
170
|
+
logger.debug(
|
|
171
|
+
f"SecurityManager doesn't recognize {resolved_path}, continuing with existence check"
|
|
172
|
+
)
|
|
173
|
+
else:
|
|
174
|
+
# Actual security denial for existing path
|
|
175
|
+
return {
|
|
176
|
+
"exists": True,
|
|
177
|
+
"path": str(
|
|
178
|
+
original_path
|
|
179
|
+
), # Return original path, not resolved
|
|
180
|
+
"is_file": False,
|
|
181
|
+
"is_directory": False,
|
|
182
|
+
"is_symlink": is_symlink,
|
|
183
|
+
"size": None,
|
|
184
|
+
"modified_time": None,
|
|
185
|
+
"error": f"Access denied: {error_msg}",
|
|
186
|
+
}
|
|
187
|
+
except Exception as security_error:
|
|
188
|
+
logger.warning(
|
|
189
|
+
f"Security check failed for {resolved_path}: {security_error}"
|
|
190
|
+
)
|
|
191
|
+
# Continue with existence check despite security error
|
|
192
|
+
logger.debug(
|
|
193
|
+
f"Continuing existence check despite security error: {security_error}"
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
# Gather additional metadata if file exists
|
|
197
|
+
try:
|
|
198
|
+
is_file = await aiofiles.os.path.isfile(resolved_path)
|
|
199
|
+
is_directory = await aiofiles.os.path.isdir(resolved_path)
|
|
200
|
+
# is_symlink was already determined above
|
|
201
|
+
|
|
202
|
+
# Get file size and modification time
|
|
203
|
+
stat_result = await aiofiles.os.stat(resolved_path)
|
|
204
|
+
file_size = stat_result.st_size if is_file else None
|
|
205
|
+
modified_time = datetime.fromtimestamp(stat_result.st_mtime)
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
"exists": True,
|
|
209
|
+
"path": str(
|
|
210
|
+
original_path
|
|
211
|
+
), # Return original path to preserve symlink paths
|
|
212
|
+
"is_file": is_file,
|
|
213
|
+
"is_directory": is_directory,
|
|
214
|
+
"is_symlink": is_symlink,
|
|
215
|
+
"size": file_size,
|
|
216
|
+
"modified_time": modified_time,
|
|
217
|
+
"error": None,
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
except OSError as os_error:
|
|
221
|
+
# File exists but we can't get metadata (permission issues, etc.)
|
|
222
|
+
logger.warning(
|
|
223
|
+
f"Could not get metadata for {resolved_path}: {os_error}"
|
|
224
|
+
)
|
|
225
|
+
return {
|
|
226
|
+
"exists": True,
|
|
227
|
+
"path": str(
|
|
228
|
+
original_path
|
|
229
|
+
), # Return original path to preserve symlink paths
|
|
230
|
+
"is_file": False,
|
|
231
|
+
"is_directory": False,
|
|
232
|
+
"is_symlink": is_symlink,
|
|
233
|
+
"size": None,
|
|
234
|
+
"modified_time": None,
|
|
235
|
+
"error": f"Metadata access failed: {str(os_error)}",
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
except Exception as e:
|
|
239
|
+
logger.error(f"Error checking file existence for {original_path}: {e}")
|
|
240
|
+
return {
|
|
241
|
+
"exists": False,
|
|
242
|
+
"path": str(original_path if "original_path" in locals() else path),
|
|
243
|
+
"is_file": False,
|
|
244
|
+
"is_directory": False,
|
|
245
|
+
"is_symlink": False,
|
|
246
|
+
"size": None,
|
|
247
|
+
"modified_time": None,
|
|
248
|
+
"error": f"Existence check failed: {str(e)}",
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async def file_exists(self, path: Union[str, Path]) -> bool:
|
|
252
|
+
"""
|
|
253
|
+
Simple boolean check for file existence.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
path: Path to check for existence
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
True if file exists and is accessible, False otherwise
|
|
260
|
+
"""
|
|
261
|
+
result = await self.check_file_exists(path)
|
|
262
|
+
return result["exists"] and result["error"] is None
|
|
263
|
+
|
|
264
|
+
async def write_file_with_confirmation(
|
|
265
|
+
self,
|
|
266
|
+
path: Union[str, Path],
|
|
267
|
+
content: Union[str, bytes],
|
|
268
|
+
encoding: str = "utf-8",
|
|
269
|
+
confirmation_callback: Optional[Callable] = None,
|
|
270
|
+
backup: bool = True,
|
|
271
|
+
atomic: bool = True,
|
|
272
|
+
read_before_write: bool = False,
|
|
273
|
+
user_review_callback: Optional[Callable] = None,
|
|
274
|
+
) -> Dict[str, Any]:
|
|
275
|
+
"""
|
|
276
|
+
Write file content with existence check and user confirmation if file exists.
|
|
277
|
+
|
|
278
|
+
Args:
|
|
279
|
+
path: Path to write to
|
|
280
|
+
content: Content to write (string or bytes)
|
|
281
|
+
encoding: File encoding (default: 'utf-8')
|
|
282
|
+
confirmation_callback: Optional callback for user confirmation on file overwrite
|
|
283
|
+
backup: Whether to create backup of existing file (default: True)
|
|
284
|
+
atomic: Whether to use atomic write operation (default: True)
|
|
285
|
+
read_before_write: Whether to use read-before-write workflow (default: False)
|
|
286
|
+
user_review_callback: Optional callback for user review when read_before_write=True
|
|
287
|
+
|
|
288
|
+
Returns:
|
|
289
|
+
Dictionary with operation result
|
|
290
|
+
"""
|
|
291
|
+
original_path = Path(path)
|
|
292
|
+
operation_id = (
|
|
293
|
+
f"write_confirm_{hashlib.md5(str(original_path).encode()).hexdigest()[:8]}"
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
try:
|
|
297
|
+
# Check if file exists with detailed information (preserving symlink info)
|
|
298
|
+
file_info = await self.check_file_exists(original_path)
|
|
299
|
+
|
|
300
|
+
# If file exists and we have a confirmation callback, ask user
|
|
301
|
+
if file_info["exists"] and confirmation_callback:
|
|
302
|
+
try:
|
|
303
|
+
confirmation_result = await confirmation_callback(file_info)
|
|
304
|
+
|
|
305
|
+
if not confirmation_result.get("confirmed", False):
|
|
306
|
+
return {
|
|
307
|
+
"success": False,
|
|
308
|
+
"operation_id": operation_id,
|
|
309
|
+
"path": str(original_path),
|
|
310
|
+
"reason": "User cancelled operation",
|
|
311
|
+
"user_decision": confirmation_result,
|
|
312
|
+
"file_exists": True,
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
# Handle different user actions
|
|
316
|
+
user_action = confirmation_result.get("action", "overwrite")
|
|
317
|
+
if user_action == "backup":
|
|
318
|
+
backup = True # Force backup if user requested it
|
|
319
|
+
elif user_action == "cancel":
|
|
320
|
+
return {
|
|
321
|
+
"success": False,
|
|
322
|
+
"operation_id": operation_id,
|
|
323
|
+
"path": str(original_path),
|
|
324
|
+
"reason": confirmation_result.get(
|
|
325
|
+
"reason", "User cancelled"
|
|
326
|
+
),
|
|
327
|
+
"user_decision": confirmation_result,
|
|
328
|
+
"file_exists": True,
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
except Exception as e:
|
|
332
|
+
logger.error(f"Error in confirmation callback: {e}")
|
|
333
|
+
return {
|
|
334
|
+
"success": False,
|
|
335
|
+
"operation_id": operation_id,
|
|
336
|
+
"path": str(original_path),
|
|
337
|
+
"reason": f"Confirmation callback error: {str(e)}",
|
|
338
|
+
"file_exists": file_info["exists"],
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
# Handle symlink replacement if needed
|
|
342
|
+
if file_info["exists"] and file_info.get("is_symlink", False):
|
|
343
|
+
# If overwriting a symlink, remove it first to create a regular file
|
|
344
|
+
logger.debug(
|
|
345
|
+
f"Removing symlink at {original_path} before writing new file"
|
|
346
|
+
)
|
|
347
|
+
await aiofiles.os.unlink(original_path)
|
|
348
|
+
|
|
349
|
+
# Proceed with regular file write
|
|
350
|
+
write_result = await self.write_file(
|
|
351
|
+
path=original_path,
|
|
352
|
+
content=content,
|
|
353
|
+
encoding=encoding,
|
|
354
|
+
backup=backup,
|
|
355
|
+
atomic=atomic,
|
|
356
|
+
read_before_write=read_before_write,
|
|
357
|
+
user_review_callback=user_review_callback,
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
# Add confirmation information to result
|
|
361
|
+
write_result.update(
|
|
362
|
+
{
|
|
363
|
+
"file_existed_before": file_info["exists"],
|
|
364
|
+
"confirmation_requested": file_info["exists"]
|
|
365
|
+
and confirmation_callback is not None,
|
|
366
|
+
"user_confirmed": file_info["exists"]
|
|
367
|
+
and confirmation_callback is not None,
|
|
368
|
+
}
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
return write_result
|
|
372
|
+
|
|
373
|
+
except Exception as e:
|
|
374
|
+
logger.error(f"Error in write_file_with_confirmation: {e}")
|
|
375
|
+
return {
|
|
376
|
+
"success": False,
|
|
377
|
+
"operation_id": operation_id,
|
|
378
|
+
"path": str(path),
|
|
379
|
+
"reason": f"Write operation failed: {str(e)}",
|
|
380
|
+
"error": str(e),
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async def read_file(
|
|
384
|
+
self,
|
|
385
|
+
path: Union[str, Path],
|
|
386
|
+
encoding: str = "utf-8",
|
|
387
|
+
binary: bool = False,
|
|
388
|
+
) -> Union[str, bytes]:
|
|
389
|
+
"""Read file content with security validation and error handling."""
|
|
390
|
+
|
|
391
|
+
path = Path(path).resolve()
|
|
392
|
+
|
|
393
|
+
# Security validation
|
|
394
|
+
result = await self.security.secure_file_access(str(path), "read")
|
|
395
|
+
if not result["success"]:
|
|
396
|
+
raise FileOperationError(f"Security check failed: {result['error']}")
|
|
397
|
+
|
|
398
|
+
try:
|
|
399
|
+
# Check if file exists
|
|
400
|
+
if not await aiofiles.os.path.exists(path):
|
|
401
|
+
raise FileOperationError(f"File not found: {path}")
|
|
402
|
+
|
|
403
|
+
# Check file size
|
|
404
|
+
stat_result = await aiofiles.os.stat(path)
|
|
405
|
+
file_size_mb = stat_result.st_size / (1024 * 1024)
|
|
406
|
+
|
|
407
|
+
if file_size_mb > self.max_file_size_mb:
|
|
408
|
+
# Use streaming for large files
|
|
409
|
+
return await self._read_large_file(path, binary, encoding)
|
|
410
|
+
|
|
411
|
+
# Regular file read
|
|
412
|
+
if binary or self._is_binary_file(path):
|
|
413
|
+
async with aiofiles.open(path, "rb") as f:
|
|
414
|
+
return await f.read()
|
|
415
|
+
else:
|
|
416
|
+
async with aiofiles.open(path, "r", encoding=encoding) as f:
|
|
417
|
+
return await f.read()
|
|
418
|
+
|
|
419
|
+
except Exception as e:
|
|
420
|
+
raise FileOperationError(f"Failed to read file {path}: {str(e)}")
|
|
421
|
+
|
|
422
|
+
async def write_file(
|
|
423
|
+
self,
|
|
424
|
+
path: Union[str, Path],
|
|
425
|
+
content: Union[str, bytes],
|
|
426
|
+
encoding: str = "utf-8",
|
|
427
|
+
backup: bool = True,
|
|
428
|
+
atomic: bool = True,
|
|
429
|
+
read_before_write: bool = False,
|
|
430
|
+
user_review_callback: Optional[Callable] = None,
|
|
431
|
+
) -> Dict[str, Any]:
|
|
432
|
+
"""
|
|
433
|
+
Write file content with atomic operations, backup, security validation, and approval.
|
|
434
|
+
|
|
435
|
+
Args:
|
|
436
|
+
path: Path to write to
|
|
437
|
+
content: Content to write (string or bytes)
|
|
438
|
+
encoding: File encoding (default: 'utf-8')
|
|
439
|
+
backup: Whether to create backup of existing file (default: True)
|
|
440
|
+
atomic: Whether to use atomic write operation (default: True)
|
|
441
|
+
read_before_write: Whether to use read-before-write workflow (default: False)
|
|
442
|
+
user_review_callback: Optional callback for user review when read_before_write=True
|
|
443
|
+
|
|
444
|
+
Returns:
|
|
445
|
+
Dictionary with operation result
|
|
446
|
+
"""
|
|
447
|
+
|
|
448
|
+
path = Path(path).resolve()
|
|
449
|
+
operation_id = f"write_{hashlib.md5(str(path).encode()).hexdigest()[:8]}"
|
|
450
|
+
|
|
451
|
+
# If read-before-write is enabled, delegate to that method
|
|
452
|
+
if read_before_write:
|
|
453
|
+
return await self.read_before_write(
|
|
454
|
+
path=path,
|
|
455
|
+
new_content=content,
|
|
456
|
+
encoding=encoding,
|
|
457
|
+
user_review_callback=user_review_callback,
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
# Validate content is not None
|
|
461
|
+
if content is None:
|
|
462
|
+
raise FileOperationError(
|
|
463
|
+
f"Content cannot be None for write operation to {path}"
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
# Create operation for approval workflow
|
|
467
|
+
file_exists = await aiofiles.os.path.exists(path)
|
|
468
|
+
operation_type = OperationType.FILE_WRITE
|
|
469
|
+
operation_description = (
|
|
470
|
+
f"{'Modify' if file_exists else 'Create'} file: {path.name}"
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
# Safely handle content for operation data
|
|
474
|
+
if content is None:
|
|
475
|
+
content_str = ""
|
|
476
|
+
content_length = 0
|
|
477
|
+
elif isinstance(content, str):
|
|
478
|
+
content_str = content[:1000] + "..." if len(content) > 1000 else content
|
|
479
|
+
content_length = len(content)
|
|
480
|
+
else:
|
|
481
|
+
# bytes content
|
|
482
|
+
decoded_content = content.decode(encoding, errors="ignore")
|
|
483
|
+
content_str = (
|
|
484
|
+
decoded_content[:1000] + "..."
|
|
485
|
+
if len(decoded_content) > 1000
|
|
486
|
+
else decoded_content
|
|
487
|
+
)
|
|
488
|
+
content_length = len(content)
|
|
489
|
+
|
|
490
|
+
operation = self._create_operation(
|
|
491
|
+
operation_type=operation_type,
|
|
492
|
+
description=operation_description,
|
|
493
|
+
data={
|
|
494
|
+
"path": str(path),
|
|
495
|
+
"content": content_str,
|
|
496
|
+
"content_length": content_length,
|
|
497
|
+
"encoding": encoding,
|
|
498
|
+
"backup": backup,
|
|
499
|
+
"atomic": atomic,
|
|
500
|
+
},
|
|
501
|
+
reversible=file_exists
|
|
502
|
+
and backup, # Reversible if we have backup capability
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
# Request approval if needed
|
|
506
|
+
approved = await self._request_approval_if_needed(operation)
|
|
507
|
+
if not approved:
|
|
508
|
+
raise FileOperationError(
|
|
509
|
+
f"File write operation denied by approval workflow: {path}"
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
# Track operation
|
|
513
|
+
self.active_operations[operation_id] = {
|
|
514
|
+
"type": "write",
|
|
515
|
+
"path": path,
|
|
516
|
+
"start_time": datetime.now(),
|
|
517
|
+
"backup_path": None,
|
|
518
|
+
"temp_path": None,
|
|
519
|
+
"approved": approved,
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
try:
|
|
523
|
+
# Use shared implementation
|
|
524
|
+
result = await self._execute_write_operation(
|
|
525
|
+
path=path,
|
|
526
|
+
content=content,
|
|
527
|
+
encoding=encoding,
|
|
528
|
+
backup=backup,
|
|
529
|
+
atomic=atomic,
|
|
530
|
+
operation_id=operation_id,
|
|
531
|
+
approved=approved,
|
|
532
|
+
)
|
|
533
|
+
return result
|
|
534
|
+
|
|
535
|
+
finally:
|
|
536
|
+
# Clean up temporary files safely
|
|
537
|
+
try:
|
|
538
|
+
if (
|
|
539
|
+
operation_id in self.active_operations
|
|
540
|
+
and "temp_path" in self.active_operations[operation_id]
|
|
541
|
+
):
|
|
542
|
+
temp_path = self.active_operations[operation_id]["temp_path"]
|
|
543
|
+
if temp_path and await aiofiles.os.path.exists(temp_path):
|
|
544
|
+
await aiofiles.os.remove(temp_path)
|
|
545
|
+
except Exception:
|
|
546
|
+
# Ignore cleanup errors - temporary files will be cleaned up eventually
|
|
547
|
+
pass
|
|
548
|
+
|
|
549
|
+
async def _execute_write_operation(
|
|
550
|
+
self,
|
|
551
|
+
path: Union[str, Path],
|
|
552
|
+
content: Union[str, bytes],
|
|
553
|
+
encoding: str = "utf-8",
|
|
554
|
+
backup: bool = True,
|
|
555
|
+
atomic: bool = True,
|
|
556
|
+
operation_id: str = None,
|
|
557
|
+
approved: bool = True,
|
|
558
|
+
) -> Dict[str, Any]:
|
|
559
|
+
"""
|
|
560
|
+
Execute the core file write operation with security, backup, and atomic operations.
|
|
561
|
+
Used by both write_file() and _perform_direct_write() to avoid code duplication.
|
|
562
|
+
"""
|
|
563
|
+
try:
|
|
564
|
+
# Security validation
|
|
565
|
+
security_result = await self.security.secure_file_access(
|
|
566
|
+
str(path),
|
|
567
|
+
"write",
|
|
568
|
+
content if isinstance(content, str) else None,
|
|
569
|
+
)
|
|
570
|
+
if not security_result["success"]:
|
|
571
|
+
raise FileOperationError(
|
|
572
|
+
f"Security check failed: {security_result['error']}"
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
# Create backup if file exists and backup is requested
|
|
576
|
+
backup_path = None
|
|
577
|
+
if backup and await aiofiles.os.path.exists(path):
|
|
578
|
+
try:
|
|
579
|
+
backup_path = await self.create_backup(path)
|
|
580
|
+
if operation_id:
|
|
581
|
+
self.active_operations[operation_id][
|
|
582
|
+
"backup_path"
|
|
583
|
+
] = backup_path
|
|
584
|
+
except Exception as e:
|
|
585
|
+
# Log backup failure but continue with write operation
|
|
586
|
+
logger.warning(f"Backup creation failed for {path}: {e}")
|
|
587
|
+
if operation_id:
|
|
588
|
+
self.active_operations[operation_id]["backup_warning"] = str(e)
|
|
589
|
+
|
|
590
|
+
# Atomic write
|
|
591
|
+
if atomic:
|
|
592
|
+
result = await self._atomic_write(path, content, encoding)
|
|
593
|
+
else:
|
|
594
|
+
result = await self._direct_write(path, content, encoding)
|
|
595
|
+
|
|
596
|
+
# Update operation tracking if operation_id provided
|
|
597
|
+
if operation_id:
|
|
598
|
+
self.active_operations[operation_id].update(result)
|
|
599
|
+
self.active_operations[operation_id]["success"] = True
|
|
600
|
+
|
|
601
|
+
return {
|
|
602
|
+
"success": True,
|
|
603
|
+
"operation_id": operation_id,
|
|
604
|
+
"path": str(path),
|
|
605
|
+
"backup_path": str(backup_path) if backup_path else None,
|
|
606
|
+
"bytes_written": len(
|
|
607
|
+
content.encode(encoding) if isinstance(content, str) else content
|
|
608
|
+
),
|
|
609
|
+
"atomic": atomic,
|
|
610
|
+
"approved": approved,
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
except Exception as e:
|
|
614
|
+
# Rollback on failure if we have backup info
|
|
615
|
+
if (
|
|
616
|
+
backup
|
|
617
|
+
and operation_id
|
|
618
|
+
and "backup_path" in self.active_operations[operation_id]
|
|
619
|
+
):
|
|
620
|
+
await self._rollback_operation(operation_id)
|
|
621
|
+
|
|
622
|
+
if operation_id:
|
|
623
|
+
self.active_operations[operation_id]["success"] = False
|
|
624
|
+
self.active_operations[operation_id]["error"] = str(e)
|
|
625
|
+
|
|
626
|
+
raise FileOperationError(f"Failed to write file {path}: {str(e)}")
|
|
627
|
+
|
|
628
|
+
async def _perform_direct_write(
|
|
629
|
+
self,
|
|
630
|
+
path: Union[str, Path],
|
|
631
|
+
content: Union[str, bytes],
|
|
632
|
+
encoding: str = "utf-8",
|
|
633
|
+
backup: bool = True,
|
|
634
|
+
atomic: bool = True,
|
|
635
|
+
) -> Dict[str, Any]:
|
|
636
|
+
"""
|
|
637
|
+
Perform direct file write bypassing the main write_file method.
|
|
638
|
+
Used internally by read_before_write to avoid infinite loops.
|
|
639
|
+
"""
|
|
640
|
+
path = Path(path).resolve()
|
|
641
|
+
operation_id = f"direct_write_{hashlib.md5(str(path).encode()).hexdigest()[:8]}"
|
|
642
|
+
|
|
643
|
+
# Validate content is not None
|
|
644
|
+
if content is None:
|
|
645
|
+
raise FileOperationError(
|
|
646
|
+
f"Content cannot be None for write operation to {path}"
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
# Track operation
|
|
650
|
+
self.active_operations[operation_id] = {
|
|
651
|
+
"type": "direct_write",
|
|
652
|
+
"path": path,
|
|
653
|
+
"start_time": datetime.now(),
|
|
654
|
+
"backup_path": None,
|
|
655
|
+
"temp_path": None,
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
try:
|
|
659
|
+
# Use shared implementation
|
|
660
|
+
result = await self._execute_write_operation(
|
|
661
|
+
path=path,
|
|
662
|
+
content=content,
|
|
663
|
+
encoding=encoding,
|
|
664
|
+
backup=backup,
|
|
665
|
+
atomic=atomic,
|
|
666
|
+
operation_id=operation_id,
|
|
667
|
+
approved=True, # Direct writes bypass approval
|
|
668
|
+
)
|
|
669
|
+
return result
|
|
670
|
+
|
|
671
|
+
finally:
|
|
672
|
+
# Clean up temporary files safely
|
|
673
|
+
try:
|
|
674
|
+
if (
|
|
675
|
+
operation_id in self.active_operations
|
|
676
|
+
and "temp_path" in self.active_operations[operation_id]
|
|
677
|
+
):
|
|
678
|
+
temp_path = self.active_operations[operation_id]["temp_path"]
|
|
679
|
+
if temp_path and await aiofiles.os.path.exists(temp_path):
|
|
680
|
+
await aiofiles.os.remove(temp_path)
|
|
681
|
+
except Exception:
|
|
682
|
+
# Ignore cleanup errors - temporary files will be cleaned up eventually
|
|
683
|
+
pass
|
|
684
|
+
|
|
685
|
+
async def create_backup(self, path: Union[str, Path]) -> Path:
|
|
686
|
+
"""Create a timestamped backup of a file."""
|
|
687
|
+
|
|
688
|
+
path = Path(path).resolve()
|
|
689
|
+
|
|
690
|
+
if not await aiofiles.os.path.exists(path):
|
|
691
|
+
raise FileOperationError(f"Cannot backup non-existent file: {path}")
|
|
692
|
+
|
|
693
|
+
# Generate backup filename with timestamp
|
|
694
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
695
|
+
backup_filename = f"{path.name}.{timestamp}.backup"
|
|
696
|
+
backup_path = self.backup_dir / backup_filename
|
|
697
|
+
|
|
698
|
+
# Ensure backup directory exists
|
|
699
|
+
backup_path.parent.mkdir(parents=True, exist_ok=True)
|
|
700
|
+
|
|
701
|
+
# Copy file to backup location
|
|
702
|
+
await self._copy_file(path, backup_path)
|
|
703
|
+
|
|
704
|
+
return backup_path
|
|
705
|
+
|
|
706
|
+
async def restore_backup(
|
|
707
|
+
self, backup_path: Union[str, Path], target_path: Union[str, Path]
|
|
708
|
+
) -> bool:
|
|
709
|
+
"""Restore a file from backup."""
|
|
710
|
+
|
|
711
|
+
backup_path = Path(backup_path)
|
|
712
|
+
target_path = Path(target_path)
|
|
713
|
+
|
|
714
|
+
if not await aiofiles.os.path.exists(backup_path):
|
|
715
|
+
raise FileOperationError(f"Backup file not found: {backup_path}")
|
|
716
|
+
|
|
717
|
+
# Security validation for target path
|
|
718
|
+
result = await self.security.secure_file_access(str(target_path), "write")
|
|
719
|
+
if not result["success"]:
|
|
720
|
+
raise FileOperationError(f"Security check failed: {result['error']}")
|
|
721
|
+
|
|
722
|
+
try:
|
|
723
|
+
await self._copy_file(backup_path, target_path)
|
|
724
|
+
return True
|
|
725
|
+
except Exception as e:
|
|
726
|
+
raise FileOperationError(f"Failed to restore backup: {str(e)}")
|
|
727
|
+
|
|
728
|
+
async def create_directory(
|
|
729
|
+
self, path: Union[str, Path], parents: bool = True
|
|
730
|
+
) -> bool:
|
|
731
|
+
"""Create directory with security validation and approval."""
|
|
732
|
+
|
|
733
|
+
path = Path(path).resolve()
|
|
734
|
+
|
|
735
|
+
# Create operation for approval workflow
|
|
736
|
+
operation = self._create_operation(
|
|
737
|
+
operation_type=OperationType.DIRECTORY_CREATE,
|
|
738
|
+
description=f"Create directory: {path.name}",
|
|
739
|
+
data={"path": str(path), "parents": parents},
|
|
740
|
+
reversible=True, # Directory creation is reversible (can be deleted)
|
|
741
|
+
)
|
|
742
|
+
|
|
743
|
+
# Request approval if needed
|
|
744
|
+
approved = await self._request_approval_if_needed(operation)
|
|
745
|
+
if not approved:
|
|
746
|
+
raise FileOperationError(
|
|
747
|
+
f"Directory creation denied by approval workflow: {path}"
|
|
748
|
+
)
|
|
749
|
+
|
|
750
|
+
# Security validation
|
|
751
|
+
perm_operation = PermissionOperation(
|
|
752
|
+
operation_type="file_create", path=str(path)
|
|
753
|
+
)
|
|
754
|
+
validation = await self.security.validate_operation(perm_operation)
|
|
755
|
+
|
|
756
|
+
if not validation["allowed"]:
|
|
757
|
+
raise FileOperationError(
|
|
758
|
+
f"Directory creation blocked: {', '.join(validation['reasons'])}"
|
|
759
|
+
)
|
|
760
|
+
|
|
761
|
+
try:
|
|
762
|
+
if parents:
|
|
763
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
764
|
+
else:
|
|
765
|
+
path.mkdir(exist_ok=True)
|
|
766
|
+
return True
|
|
767
|
+
except Exception as e:
|
|
768
|
+
raise FileOperationError(f"Failed to create directory {path}: {str(e)}")
|
|
769
|
+
|
|
770
|
+
async def delete_file(
|
|
771
|
+
self, path: Union[str, Path], backup: bool = True
|
|
772
|
+
) -> Dict[str, Any]:
|
|
773
|
+
"""Delete file with optional backup and approval."""
|
|
774
|
+
|
|
775
|
+
path = Path(path).resolve()
|
|
776
|
+
|
|
777
|
+
# Create operation for approval workflow
|
|
778
|
+
operation = self._create_operation(
|
|
779
|
+
operation_type=OperationType.FILE_DELETE,
|
|
780
|
+
description=f"Delete file: {path.name}",
|
|
781
|
+
data={"path": str(path), "backup": backup},
|
|
782
|
+
reversible=backup, # Reversible if we create backup
|
|
783
|
+
)
|
|
784
|
+
|
|
785
|
+
# Request approval if needed
|
|
786
|
+
approved = await self._request_approval_if_needed(operation)
|
|
787
|
+
if not approved:
|
|
788
|
+
raise FileOperationError(
|
|
789
|
+
f"File delete operation denied by approval workflow: {path}"
|
|
790
|
+
)
|
|
791
|
+
|
|
792
|
+
# Security validation
|
|
793
|
+
result = await self.security.secure_file_access(str(path), "delete")
|
|
794
|
+
if not result["success"]:
|
|
795
|
+
raise FileOperationError(f"Security check failed: {result['error']}")
|
|
796
|
+
|
|
797
|
+
backup_path = None
|
|
798
|
+
try:
|
|
799
|
+
# Create backup if requested
|
|
800
|
+
if backup and await aiofiles.os.path.exists(path):
|
|
801
|
+
backup_path = await self.create_backup(path)
|
|
802
|
+
|
|
803
|
+
# Delete the file
|
|
804
|
+
await aiofiles.os.remove(path)
|
|
805
|
+
|
|
806
|
+
return {
|
|
807
|
+
"success": True,
|
|
808
|
+
"path": str(path),
|
|
809
|
+
"backup_path": str(backup_path) if backup_path else None,
|
|
810
|
+
"approved": approved,
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
except Exception as e:
|
|
814
|
+
raise FileOperationError(f"Failed to delete file {path}: {str(e)}")
|
|
815
|
+
|
|
816
|
+
async def glob_files(self, pattern: str, recursive: bool = True) -> List[Path]:
|
|
817
|
+
"""Find files matching glob pattern with security validation."""
|
|
818
|
+
|
|
819
|
+
try:
|
|
820
|
+
# Use glob to find matching files
|
|
821
|
+
if recursive:
|
|
822
|
+
matches = glob_module.glob(pattern, recursive=True)
|
|
823
|
+
else:
|
|
824
|
+
matches = glob_module.glob(pattern)
|
|
825
|
+
|
|
826
|
+
# Convert to Path objects and validate each
|
|
827
|
+
validated_paths = []
|
|
828
|
+
for match in matches:
|
|
829
|
+
path = Path(match).resolve()
|
|
830
|
+
|
|
831
|
+
# Basic security check for each path
|
|
832
|
+
try:
|
|
833
|
+
result = await self.security.secure_file_access(str(path), "read")
|
|
834
|
+
if result["success"]:
|
|
835
|
+
validated_paths.append(path)
|
|
836
|
+
except:
|
|
837
|
+
# Skip files that fail security validation
|
|
838
|
+
continue
|
|
839
|
+
|
|
840
|
+
return validated_paths
|
|
841
|
+
|
|
842
|
+
except Exception as e:
|
|
843
|
+
raise FileOperationError(f"Glob pattern matching failed: {str(e)}")
|
|
844
|
+
|
|
845
|
+
async def copy_file(self, src: Union[str, Path], dst: Union[str, Path]) -> bool:
|
|
846
|
+
"""Copy file with security validation."""
|
|
847
|
+
|
|
848
|
+
src = Path(src).resolve()
|
|
849
|
+
dst = Path(dst).resolve()
|
|
850
|
+
|
|
851
|
+
# Validate source read access
|
|
852
|
+
read_result = await self.security.secure_file_access(str(src), "read")
|
|
853
|
+
if not read_result["success"]:
|
|
854
|
+
raise FileOperationError(
|
|
855
|
+
f"Source read access denied: {read_result['error']}"
|
|
856
|
+
)
|
|
857
|
+
|
|
858
|
+
# Validate destination write access
|
|
859
|
+
write_result = await self.security.secure_file_access(str(dst), "write")
|
|
860
|
+
if not write_result["success"]:
|
|
861
|
+
raise FileOperationError(
|
|
862
|
+
f"Destination write access denied: {write_result['error']}"
|
|
863
|
+
)
|
|
864
|
+
|
|
865
|
+
try:
|
|
866
|
+
await self._copy_file(src, dst)
|
|
867
|
+
return True
|
|
868
|
+
except Exception as e:
|
|
869
|
+
raise FileOperationError(f"Failed to copy file: {str(e)}")
|
|
870
|
+
|
|
871
|
+
async def move_file(
|
|
872
|
+
self, src: Union[str, Path], dst: Union[str, Path], backup: bool = True
|
|
873
|
+
) -> Dict[str, Any]:
|
|
874
|
+
"""Move file with security validation, approval, and optional backup."""
|
|
875
|
+
|
|
876
|
+
src = Path(src).resolve()
|
|
877
|
+
dst = Path(dst).resolve()
|
|
878
|
+
|
|
879
|
+
# Create operation for approval workflow
|
|
880
|
+
operation = self._create_operation(
|
|
881
|
+
operation_type=OperationType.FILE_WRITE, # Move involves writing to new location
|
|
882
|
+
description=f"Move file: {src.name} → {dst.name}",
|
|
883
|
+
data={
|
|
884
|
+
"source_path": str(src),
|
|
885
|
+
"destination_path": str(dst),
|
|
886
|
+
"backup": backup,
|
|
887
|
+
},
|
|
888
|
+
reversible=backup, # Reversible if we create backup
|
|
889
|
+
)
|
|
890
|
+
|
|
891
|
+
# Request approval if needed
|
|
892
|
+
approved = await self._request_approval_if_needed(operation)
|
|
893
|
+
if not approved:
|
|
894
|
+
raise FileOperationError(
|
|
895
|
+
f"File move operation denied by approval workflow: {src} → {dst}"
|
|
896
|
+
)
|
|
897
|
+
|
|
898
|
+
# Copy first, then delete source
|
|
899
|
+
await self.copy_file(src, dst)
|
|
900
|
+
delete_result = await self.delete_file(src, backup=backup)
|
|
901
|
+
|
|
902
|
+
return {
|
|
903
|
+
"success": True,
|
|
904
|
+
"source": str(src),
|
|
905
|
+
"destination": str(dst),
|
|
906
|
+
"backup_path": delete_result.get("backup_path"),
|
|
907
|
+
"approved": approved,
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
async def get_file_info(self, path: Union[str, Path]) -> Dict[str, Any]:
|
|
911
|
+
"""Get comprehensive file information."""
|
|
912
|
+
|
|
913
|
+
path = Path(path).resolve()
|
|
914
|
+
|
|
915
|
+
# Security validation
|
|
916
|
+
result = await self.security.secure_file_access(str(path), "read")
|
|
917
|
+
if not result["success"]:
|
|
918
|
+
raise FileOperationError(f"Security check failed: {result['error']}")
|
|
919
|
+
|
|
920
|
+
try:
|
|
921
|
+
if not await aiofiles.os.path.exists(path):
|
|
922
|
+
raise FileOperationError(f"File not found: {path}")
|
|
923
|
+
|
|
924
|
+
stat_result = await aiofiles.os.stat(path)
|
|
925
|
+
|
|
926
|
+
info = {
|
|
927
|
+
"path": str(path),
|
|
928
|
+
"name": path.name,
|
|
929
|
+
"size": stat_result.st_size,
|
|
930
|
+
"size_mb": stat_result.st_size / (1024 * 1024),
|
|
931
|
+
"modified": datetime.fromtimestamp(stat_result.st_mtime),
|
|
932
|
+
"created": datetime.fromtimestamp(stat_result.st_ctime),
|
|
933
|
+
"is_file": path.is_file(),
|
|
934
|
+
"is_dir": path.is_dir(),
|
|
935
|
+
"is_binary": (self._is_binary_file(path) if path.is_file() else False),
|
|
936
|
+
"mime_type": mimetypes.guess_type(str(path))[0],
|
|
937
|
+
"permissions": oct(stat_result.st_mode)[-3:],
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
# Add file hash for files
|
|
941
|
+
if (
|
|
942
|
+
path.is_file() and stat_result.st_size < 10 * 1024 * 1024
|
|
943
|
+
): # Only for files < 10MB
|
|
944
|
+
content = await self.read_file(path, binary=True)
|
|
945
|
+
info["md5_hash"] = hashlib.md5(content).hexdigest()
|
|
946
|
+
info["sha256_hash"] = hashlib.sha256(content).hexdigest()
|
|
947
|
+
|
|
948
|
+
return info
|
|
949
|
+
|
|
950
|
+
except Exception as e:
|
|
951
|
+
raise FileOperationError(f"Failed to get file info: {str(e)}")
|
|
952
|
+
|
|
953
|
+
async def list_directory(
|
|
954
|
+
self, path: Union[str, Path], recursive: bool = False
|
|
955
|
+
) -> List[Dict[str, Any]]:
|
|
956
|
+
"""List directory contents with file information."""
|
|
957
|
+
|
|
958
|
+
path = Path(path).resolve()
|
|
959
|
+
|
|
960
|
+
# Security validation
|
|
961
|
+
result = await self.security.secure_file_access(str(path), "read")
|
|
962
|
+
if not result["success"]:
|
|
963
|
+
raise FileOperationError(f"Security check failed: {result['error']}")
|
|
964
|
+
|
|
965
|
+
try:
|
|
966
|
+
if not path.is_dir():
|
|
967
|
+
raise FileOperationError(f"Path is not a directory: {path}")
|
|
968
|
+
|
|
969
|
+
items = []
|
|
970
|
+
|
|
971
|
+
if recursive:
|
|
972
|
+
for item in path.rglob("*"):
|
|
973
|
+
try:
|
|
974
|
+
info = await self.get_file_info(item)
|
|
975
|
+
items.append(info)
|
|
976
|
+
except:
|
|
977
|
+
# Skip items that can't be accessed
|
|
978
|
+
continue
|
|
979
|
+
else:
|
|
980
|
+
for item in path.iterdir():
|
|
981
|
+
try:
|
|
982
|
+
info = await self.get_file_info(item)
|
|
983
|
+
items.append(info)
|
|
984
|
+
except:
|
|
985
|
+
# Skip items that can't be accessed
|
|
986
|
+
continue
|
|
987
|
+
|
|
988
|
+
return sorted(items, key=lambda x: (not x["is_dir"], x["name"].lower()))
|
|
989
|
+
|
|
990
|
+
except Exception as e:
|
|
991
|
+
raise FileOperationError(f"Failed to list directory: {str(e)}")
|
|
992
|
+
|
|
993
|
+
# Private helper methods
|
|
994
|
+
|
|
995
|
+
def _is_binary_file(self, path: Path) -> bool:
|
|
996
|
+
"""Detect if file is binary."""
|
|
997
|
+
try:
|
|
998
|
+
with open(path, "rb") as f:
|
|
999
|
+
chunk = f.read(8192)
|
|
1000
|
+
return b"\x00" in chunk
|
|
1001
|
+
except:
|
|
1002
|
+
return False
|
|
1003
|
+
|
|
1004
|
+
async def _atomic_write(
|
|
1005
|
+
self, path: Path, content: Union[str, bytes], encoding: str
|
|
1006
|
+
) -> Dict[str, Any]:
|
|
1007
|
+
"""Perform atomic write using temporary file."""
|
|
1008
|
+
|
|
1009
|
+
# Create temporary file in same directory
|
|
1010
|
+
temp_path = path.parent / f".{path.name}.tmp.{os.getpid()}"
|
|
1011
|
+
|
|
1012
|
+
try:
|
|
1013
|
+
# Write to temporary file
|
|
1014
|
+
if isinstance(content, bytes):
|
|
1015
|
+
async with aiofiles.open(temp_path, "wb") as f:
|
|
1016
|
+
await f.write(content)
|
|
1017
|
+
else:
|
|
1018
|
+
async with aiofiles.open(temp_path, "w", encoding=encoding) as f:
|
|
1019
|
+
await f.write(content)
|
|
1020
|
+
|
|
1021
|
+
# Atomic move
|
|
1022
|
+
await aiofiles.os.rename(temp_path, path)
|
|
1023
|
+
|
|
1024
|
+
return {
|
|
1025
|
+
"method": "atomic",
|
|
1026
|
+
"temp_path": temp_path,
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
except Exception as e:
|
|
1030
|
+
# Clean up temp file on failure
|
|
1031
|
+
if await aiofiles.os.path.exists(temp_path):
|
|
1032
|
+
await aiofiles.os.remove(temp_path)
|
|
1033
|
+
raise e
|
|
1034
|
+
|
|
1035
|
+
async def _direct_write(
|
|
1036
|
+
self, path: Path, content: Union[str, bytes], encoding: str
|
|
1037
|
+
) -> Dict[str, Any]:
|
|
1038
|
+
"""Perform direct write to file."""
|
|
1039
|
+
|
|
1040
|
+
# Ensure parent directory exists
|
|
1041
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1042
|
+
|
|
1043
|
+
if isinstance(content, bytes):
|
|
1044
|
+
async with aiofiles.open(path, "wb") as f:
|
|
1045
|
+
await f.write(content)
|
|
1046
|
+
else:
|
|
1047
|
+
async with aiofiles.open(path, "w", encoding=encoding) as f:
|
|
1048
|
+
await f.write(content)
|
|
1049
|
+
|
|
1050
|
+
return {
|
|
1051
|
+
"method": "direct",
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
async def _copy_file(self, src: Path, dst: Path) -> None:
|
|
1055
|
+
"""Copy file using async operations."""
|
|
1056
|
+
|
|
1057
|
+
# Ensure destination directory exists
|
|
1058
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
1059
|
+
|
|
1060
|
+
async with aiofiles.open(src, "rb") as src_file:
|
|
1061
|
+
async with aiofiles.open(dst, "wb") as dst_file:
|
|
1062
|
+
while True:
|
|
1063
|
+
chunk = await src_file.read(self.chunk_size)
|
|
1064
|
+
if not chunk:
|
|
1065
|
+
break
|
|
1066
|
+
await dst_file.write(chunk)
|
|
1067
|
+
|
|
1068
|
+
async def _read_large_file(
|
|
1069
|
+
self, path: Path, binary: bool, encoding: str
|
|
1070
|
+
) -> Union[str, bytes]:
|
|
1071
|
+
"""Read large file in chunks."""
|
|
1072
|
+
|
|
1073
|
+
chunks = []
|
|
1074
|
+
|
|
1075
|
+
if binary:
|
|
1076
|
+
async with aiofiles.open(path, "rb") as f:
|
|
1077
|
+
while True:
|
|
1078
|
+
chunk = await f.read(self.chunk_size)
|
|
1079
|
+
if not chunk:
|
|
1080
|
+
break
|
|
1081
|
+
chunks.append(chunk)
|
|
1082
|
+
return b"".join(chunks)
|
|
1083
|
+
else:
|
|
1084
|
+
async with aiofiles.open(path, "r", encoding=encoding) as f:
|
|
1085
|
+
while True:
|
|
1086
|
+
chunk = await f.read(self.chunk_size)
|
|
1087
|
+
if not chunk:
|
|
1088
|
+
break
|
|
1089
|
+
chunks.append(chunk)
|
|
1090
|
+
return "".join(chunks)
|
|
1091
|
+
|
|
1092
|
+
async def _rollback_operation(self, operation_id: str) -> bool:
|
|
1093
|
+
"""Rollback a failed operation using backup."""
|
|
1094
|
+
|
|
1095
|
+
if operation_id not in self.active_operations:
|
|
1096
|
+
return False
|
|
1097
|
+
|
|
1098
|
+
operation = self.active_operations[operation_id]
|
|
1099
|
+
backup_path = operation.get("backup_path")
|
|
1100
|
+
original_path = operation.get("path")
|
|
1101
|
+
|
|
1102
|
+
if backup_path and original_path and await aiofiles.os.path.exists(backup_path):
|
|
1103
|
+
try:
|
|
1104
|
+
await self._copy_file(Path(backup_path), Path(original_path))
|
|
1105
|
+
return True
|
|
1106
|
+
except:
|
|
1107
|
+
return False
|
|
1108
|
+
|
|
1109
|
+
return False
|
|
1110
|
+
|
|
1111
|
+
# Git integration methods
|
|
1112
|
+
|
|
1113
|
+
async def git_add(self, path: Union[str, Path]) -> bool:
|
|
1114
|
+
"""Add file to git staging area."""
|
|
1115
|
+
|
|
1116
|
+
path = Path(path).resolve()
|
|
1117
|
+
|
|
1118
|
+
# Check if we're in a git repository
|
|
1119
|
+
try:
|
|
1120
|
+
result = await self.security.execute_secure_command(
|
|
1121
|
+
["git", "rev-parse", "--git-dir"], working_dir=str(path.parent)
|
|
1122
|
+
)
|
|
1123
|
+
if not result["success"]:
|
|
1124
|
+
raise FileOperationError("Not in a git repository")
|
|
1125
|
+
except:
|
|
1126
|
+
raise FileOperationError("Git not available or not in a git repository")
|
|
1127
|
+
|
|
1128
|
+
# Add file to git
|
|
1129
|
+
try:
|
|
1130
|
+
result = await self.security.execute_secure_command(
|
|
1131
|
+
["git", "add", str(path)], working_dir=str(path.parent)
|
|
1132
|
+
)
|
|
1133
|
+
return result["success"]
|
|
1134
|
+
except Exception as e:
|
|
1135
|
+
raise FileOperationError(f"Git add failed: {str(e)}")
|
|
1136
|
+
|
|
1137
|
+
async def git_status(
|
|
1138
|
+
self, path: Optional[Union[str, Path]] = None
|
|
1139
|
+
) -> Dict[str, Any]:
|
|
1140
|
+
"""Get git status for path or current directory."""
|
|
1141
|
+
|
|
1142
|
+
working_dir = str(Path(path).parent if path else Path.cwd())
|
|
1143
|
+
|
|
1144
|
+
try:
|
|
1145
|
+
result = await self.security.execute_secure_command(
|
|
1146
|
+
["git", "status", "--porcelain"], working_dir=working_dir
|
|
1147
|
+
)
|
|
1148
|
+
|
|
1149
|
+
if result["success"]:
|
|
1150
|
+
return {
|
|
1151
|
+
"success": True,
|
|
1152
|
+
"status_output": result["stdout"],
|
|
1153
|
+
"changes": (
|
|
1154
|
+
result["stdout"].strip().split("\n")
|
|
1155
|
+
if result["stdout"].strip()
|
|
1156
|
+
else []
|
|
1157
|
+
),
|
|
1158
|
+
}
|
|
1159
|
+
else:
|
|
1160
|
+
return {
|
|
1161
|
+
"success": False,
|
|
1162
|
+
"error": result["stderr"],
|
|
1163
|
+
}
|
|
1164
|
+
except Exception as e:
|
|
1165
|
+
raise FileOperationError(f"Git status failed: {str(e)}")
|
|
1166
|
+
|
|
1167
|
+
# Context managers and streaming
|
|
1168
|
+
|
|
1169
|
+
@asynccontextmanager
|
|
1170
|
+
async def streaming_read(
|
|
1171
|
+
self, path: Union[str, Path], chunk_size: Optional[int] = None
|
|
1172
|
+
):
|
|
1173
|
+
"""Context manager for streaming file reads."""
|
|
1174
|
+
|
|
1175
|
+
path = Path(path).resolve()
|
|
1176
|
+
chunk_size = chunk_size or self.chunk_size
|
|
1177
|
+
|
|
1178
|
+
# Security validation
|
|
1179
|
+
result = await self.security.secure_file_access(str(path), "read")
|
|
1180
|
+
if not result["success"]:
|
|
1181
|
+
raise FileOperationError(f"Security check failed: {result['error']}")
|
|
1182
|
+
|
|
1183
|
+
async with aiofiles.open(path, "rb") as f:
|
|
1184
|
+
yield f
|
|
1185
|
+
|
|
1186
|
+
async def get_operation_status(self, operation_id: str) -> Optional[Dict[str, Any]]:
|
|
1187
|
+
"""Get status of a tracked operation."""
|
|
1188
|
+
return self.active_operations.get(operation_id)
|
|
1189
|
+
|
|
1190
|
+
def clear_completed_operations(self) -> int:
|
|
1191
|
+
"""Clear completed operations from tracking."""
|
|
1192
|
+
completed = [
|
|
1193
|
+
op_id
|
|
1194
|
+
for op_id, op in self.active_operations.items()
|
|
1195
|
+
if op.get("success") is not None
|
|
1196
|
+
]
|
|
1197
|
+
|
|
1198
|
+
for op_id in completed:
|
|
1199
|
+
del self.active_operations[op_id]
|
|
1200
|
+
|
|
1201
|
+
return len(completed)
|
|
1202
|
+
|
|
1203
|
+
# Read-before-write functionality
|
|
1204
|
+
|
|
1205
|
+
async def read_before_write(
|
|
1206
|
+
self,
|
|
1207
|
+
path: Union[str, Path],
|
|
1208
|
+
new_content: Union[str, bytes],
|
|
1209
|
+
encoding: str = "utf-8",
|
|
1210
|
+
user_review_callback: Optional[Callable] = None,
|
|
1211
|
+
max_retries: int = 2,
|
|
1212
|
+
) -> Dict[str, Any]:
|
|
1213
|
+
"""
|
|
1214
|
+
Read existing file content before write and present to user for review.
|
|
1215
|
+
|
|
1216
|
+
This method implements read-before-write logic by:
|
|
1217
|
+
1. Reading current file content if it exists
|
|
1218
|
+
2. Presenting both current and new content to user for review
|
|
1219
|
+
3. Allowing user to approve, modify, or reject the change
|
|
1220
|
+
|
|
1221
|
+
Args:
|
|
1222
|
+
path: Path to the file to be written
|
|
1223
|
+
new_content: New content to write to the file
|
|
1224
|
+
encoding: File encoding for text files
|
|
1225
|
+
user_review_callback: Callback function for user review interface
|
|
1226
|
+
max_retries: Maximum number of retry attempts for recoverable errors
|
|
1227
|
+
|
|
1228
|
+
Returns:
|
|
1229
|
+
Dict with operation result and user decision
|
|
1230
|
+
"""
|
|
1231
|
+
return await self._read_before_write_with_recovery(
|
|
1232
|
+
path=path,
|
|
1233
|
+
new_content=new_content,
|
|
1234
|
+
encoding=encoding,
|
|
1235
|
+
user_review_callback=user_review_callback,
|
|
1236
|
+
max_retries=max_retries,
|
|
1237
|
+
retry_count=0,
|
|
1238
|
+
)
|
|
1239
|
+
|
|
1240
|
+
async def _read_before_write_with_recovery(
|
|
1241
|
+
self,
|
|
1242
|
+
path: Union[str, Path],
|
|
1243
|
+
new_content: Union[str, bytes],
|
|
1244
|
+
encoding: str = "utf-8",
|
|
1245
|
+
user_review_callback: Optional[Callable] = None,
|
|
1246
|
+
max_retries: int = 2,
|
|
1247
|
+
retry_count: int = 0,
|
|
1248
|
+
) -> Dict[str, Any]:
|
|
1249
|
+
"""
|
|
1250
|
+
Internal method with error recovery for read-before-write operations.
|
|
1251
|
+
"""
|
|
1252
|
+
path = Path(path).resolve()
|
|
1253
|
+
operation_type = (
|
|
1254
|
+
"create" if not await aiofiles.os.path.exists(path) else "modify"
|
|
1255
|
+
)
|
|
1256
|
+
|
|
1257
|
+
try:
|
|
1258
|
+
# Read current content if file exists
|
|
1259
|
+
current_content = None
|
|
1260
|
+
file_exists = await aiofiles.os.path.exists(path)
|
|
1261
|
+
|
|
1262
|
+
if file_exists:
|
|
1263
|
+
try:
|
|
1264
|
+
current_content = await self.read_file(path, encoding=encoding)
|
|
1265
|
+
except Exception as e:
|
|
1266
|
+
# Handle file read error
|
|
1267
|
+
read_error = FileReadError(str(path), e)
|
|
1268
|
+
error_result = self.error_handler.handle_error(
|
|
1269
|
+
read_error, retry_count, max_retries
|
|
1270
|
+
)
|
|
1271
|
+
|
|
1272
|
+
if error_result.get("should_retry"):
|
|
1273
|
+
return await self._read_before_write_with_recovery(
|
|
1274
|
+
path,
|
|
1275
|
+
new_content,
|
|
1276
|
+
encoding,
|
|
1277
|
+
user_review_callback,
|
|
1278
|
+
max_retries,
|
|
1279
|
+
retry_count + 1,
|
|
1280
|
+
)
|
|
1281
|
+
elif error_result.get("fallback_action") == "regular_write":
|
|
1282
|
+
logger.warning(
|
|
1283
|
+
f"Falling back to regular write for {path} due to read error"
|
|
1284
|
+
)
|
|
1285
|
+
return await self._perform_direct_write(
|
|
1286
|
+
path=path, content=new_content, encoding=encoding
|
|
1287
|
+
)
|
|
1288
|
+
elif error_result.get("should_abort"):
|
|
1289
|
+
return {
|
|
1290
|
+
"success": False,
|
|
1291
|
+
"error": f"Failed to read existing content: {str(e)}",
|
|
1292
|
+
"operation": "read_before_write",
|
|
1293
|
+
"error_details": error_result,
|
|
1294
|
+
}
|
|
1295
|
+
else:
|
|
1296
|
+
# Continue with warning
|
|
1297
|
+
current_content = f"<Error reading file: {str(e)}>"
|
|
1298
|
+
logger.warning(f"Continuing with read error for {path}: {e}")
|
|
1299
|
+
|
|
1300
|
+
# Prepare review data
|
|
1301
|
+
review_data = {
|
|
1302
|
+
"file_path": str(path),
|
|
1303
|
+
"file_exists": file_exists,
|
|
1304
|
+
"current_content": current_content,
|
|
1305
|
+
"new_content": (
|
|
1306
|
+
new_content
|
|
1307
|
+
if isinstance(new_content, str)
|
|
1308
|
+
else new_content.decode(encoding, errors="ignore")
|
|
1309
|
+
),
|
|
1310
|
+
"encoding": encoding,
|
|
1311
|
+
"operation": operation_type,
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
# Generate diff if both contents exist
|
|
1315
|
+
try:
|
|
1316
|
+
if current_content and not current_content.startswith(
|
|
1317
|
+
"<Error reading file:"
|
|
1318
|
+
):
|
|
1319
|
+
if isinstance(new_content, str):
|
|
1320
|
+
review_data["diff"] = self._generate_content_diff(
|
|
1321
|
+
current_content, new_content, str(path)
|
|
1322
|
+
)
|
|
1323
|
+
elif isinstance(new_content, bytes):
|
|
1324
|
+
review_data["diff"] = self._generate_content_diff(
|
|
1325
|
+
current_content,
|
|
1326
|
+
new_content.decode(encoding, errors="ignore"),
|
|
1327
|
+
str(path),
|
|
1328
|
+
)
|
|
1329
|
+
except Exception as e:
|
|
1330
|
+
# Handle diff generation error
|
|
1331
|
+
diff_error = DiffGenerationError(str(path), e)
|
|
1332
|
+
error_result = self.error_handler.handle_error(
|
|
1333
|
+
diff_error, retry_count, max_retries
|
|
1334
|
+
)
|
|
1335
|
+
|
|
1336
|
+
if error_result.get("fallback_action") == "continue_with_warning":
|
|
1337
|
+
logger.warning(f"Continuing without diff for {path}: {e}")
|
|
1338
|
+
review_data["diff"] = f"<Error generating diff: {str(e)}>"
|
|
1339
|
+
else:
|
|
1340
|
+
review_data["diff"] = None
|
|
1341
|
+
|
|
1342
|
+
# Present to user for review if callback provided
|
|
1343
|
+
user_decision = {"approved": True, "modified_content": None}
|
|
1344
|
+
if user_review_callback:
|
|
1345
|
+
try:
|
|
1346
|
+
user_decision = await user_review_callback(review_data)
|
|
1347
|
+
except Exception as e:
|
|
1348
|
+
# Handle callback error
|
|
1349
|
+
callback_error = CallbackError(str(path), e)
|
|
1350
|
+
error_result = self.error_handler.handle_error(
|
|
1351
|
+
callback_error, retry_count, max_retries
|
|
1352
|
+
)
|
|
1353
|
+
|
|
1354
|
+
if error_result.get("should_retry"):
|
|
1355
|
+
return await self._read_before_write_with_recovery(
|
|
1356
|
+
path,
|
|
1357
|
+
new_content,
|
|
1358
|
+
encoding,
|
|
1359
|
+
user_review_callback,
|
|
1360
|
+
max_retries,
|
|
1361
|
+
retry_count + 1,
|
|
1362
|
+
)
|
|
1363
|
+
elif error_result.get("fallback_action") == "regular_write":
|
|
1364
|
+
logger.warning(
|
|
1365
|
+
f"Falling back to regular write for {path} due to callback error"
|
|
1366
|
+
)
|
|
1367
|
+
return await self._perform_direct_write(
|
|
1368
|
+
path=path, content=new_content, encoding=encoding
|
|
1369
|
+
)
|
|
1370
|
+
else:
|
|
1371
|
+
return {
|
|
1372
|
+
"success": False,
|
|
1373
|
+
"error": f"User review failed: {str(e)}",
|
|
1374
|
+
"operation": "read_before_write",
|
|
1375
|
+
"error_details": error_result,
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
# Handle user decision - CRITICAL FIX for cancellation
|
|
1379
|
+
if user_decision.get("cancelled"):
|
|
1380
|
+
# User pressed 'q' to quit - exit immediately without retry
|
|
1381
|
+
logger.info(f"🛑 User cancelled file operation for {path}")
|
|
1382
|
+
return {
|
|
1383
|
+
"success": False,
|
|
1384
|
+
"error": "User cancelled operation",
|
|
1385
|
+
"operation": "read_before_write",
|
|
1386
|
+
"user_decision": user_decision,
|
|
1387
|
+
"cancelled": True, # Mark as cancelled for upstream handling
|
|
1388
|
+
"final": True, # No retries should be attempted
|
|
1389
|
+
}
|
|
1390
|
+
elif not user_decision.get("approved", False):
|
|
1391
|
+
from .read_before_write_errors import UserRejectionError
|
|
1392
|
+
|
|
1393
|
+
rejection_reason = user_decision.get("reason", "User rejected changes")
|
|
1394
|
+
rejection_error = UserRejectionError(str(path), rejection_reason)
|
|
1395
|
+
error_result = self.error_handler.handle_error(
|
|
1396
|
+
rejection_error, retry_count, max_retries
|
|
1397
|
+
)
|
|
1398
|
+
|
|
1399
|
+
return {
|
|
1400
|
+
"success": False,
|
|
1401
|
+
"error": "User rejected the file modification",
|
|
1402
|
+
"operation": "read_before_write",
|
|
1403
|
+
"user_decision": user_decision,
|
|
1404
|
+
"error_details": error_result,
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
# Validate content if modified by user
|
|
1408
|
+
final_content = user_decision.get("modified_content", new_content)
|
|
1409
|
+
|
|
1410
|
+
# Ensure final_content is not None
|
|
1411
|
+
if final_content is None:
|
|
1412
|
+
final_content = new_content
|
|
1413
|
+
|
|
1414
|
+
if user_decision.get("modified_content") is not None:
|
|
1415
|
+
try:
|
|
1416
|
+
self._validate_content(final_content, str(path))
|
|
1417
|
+
except Exception as validation_error:
|
|
1418
|
+
content_error = ContentValidationError(
|
|
1419
|
+
str(path), str(validation_error)
|
|
1420
|
+
)
|
|
1421
|
+
error_result = self.error_handler.handle_error(
|
|
1422
|
+
content_error, retry_count, max_retries
|
|
1423
|
+
)
|
|
1424
|
+
|
|
1425
|
+
if error_result.get("fallback_action") == "prompt_user":
|
|
1426
|
+
return {
|
|
1427
|
+
"success": False,
|
|
1428
|
+
"error": f"Content validation failed: {str(validation_error)}",
|
|
1429
|
+
"operation": "read_before_write",
|
|
1430
|
+
"error_details": error_result,
|
|
1431
|
+
"requires_user_action": True,
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
# Proceed with write operation - CRITICAL FIX for infinite loop
|
|
1435
|
+
try:
|
|
1436
|
+
# Skip read_before_write to avoid calling the replaced write_file method again
|
|
1437
|
+
# This directly performs the file write without triggering approval again
|
|
1438
|
+
write_result = await self._perform_direct_write(
|
|
1439
|
+
path=path,
|
|
1440
|
+
content=final_content,
|
|
1441
|
+
encoding=encoding,
|
|
1442
|
+
backup=True, # Always create backup in read-before-write mode
|
|
1443
|
+
atomic=True,
|
|
1444
|
+
)
|
|
1445
|
+
|
|
1446
|
+
# Add read-before-write specific metadata
|
|
1447
|
+
write_result["operation"] = "read_before_write"
|
|
1448
|
+
write_result["had_existing_content"] = file_exists
|
|
1449
|
+
write_result["user_reviewed"] = bool(user_review_callback)
|
|
1450
|
+
write_result["content_modified_by_user"] = (
|
|
1451
|
+
user_decision.get("modified_content") is not None
|
|
1452
|
+
)
|
|
1453
|
+
write_result["retry_count"] = retry_count
|
|
1454
|
+
|
|
1455
|
+
return write_result
|
|
1456
|
+
|
|
1457
|
+
except Exception as e:
|
|
1458
|
+
# Handle write error
|
|
1459
|
+
write_error = FileWriteError(str(path), e)
|
|
1460
|
+
error_result = self.error_handler.handle_error(
|
|
1461
|
+
write_error, retry_count, max_retries
|
|
1462
|
+
)
|
|
1463
|
+
|
|
1464
|
+
if error_result.get("should_retry"):
|
|
1465
|
+
return await self._read_before_write_with_recovery(
|
|
1466
|
+
path,
|
|
1467
|
+
new_content,
|
|
1468
|
+
encoding,
|
|
1469
|
+
user_review_callback,
|
|
1470
|
+
max_retries,
|
|
1471
|
+
retry_count + 1,
|
|
1472
|
+
)
|
|
1473
|
+
else:
|
|
1474
|
+
return {
|
|
1475
|
+
"success": False,
|
|
1476
|
+
"error": f"Write operation failed: {str(e)}",
|
|
1477
|
+
"operation": "read_before_write",
|
|
1478
|
+
"error_details": error_result,
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
except Exception as e:
|
|
1482
|
+
# Handle any unexpected errors
|
|
1483
|
+
logger.error(
|
|
1484
|
+
f"Unexpected error in read_before_write for {path}: {e}",
|
|
1485
|
+
exc_info=True,
|
|
1486
|
+
)
|
|
1487
|
+
return {
|
|
1488
|
+
"success": False,
|
|
1489
|
+
"error": f"Unexpected error: {str(e)}",
|
|
1490
|
+
"operation": "read_before_write",
|
|
1491
|
+
"retry_count": retry_count,
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
def _validate_content(self, content: Union[str, bytes], file_path: str):
|
|
1495
|
+
"""
|
|
1496
|
+
Validate file content before writing.
|
|
1497
|
+
|
|
1498
|
+
Args:
|
|
1499
|
+
content: Content to validate
|
|
1500
|
+
file_path: Path to the file for context
|
|
1501
|
+
|
|
1502
|
+
Raises:
|
|
1503
|
+
ValueError: If content validation fails
|
|
1504
|
+
"""
|
|
1505
|
+
if content is None:
|
|
1506
|
+
raise ValueError("Content cannot be None")
|
|
1507
|
+
|
|
1508
|
+
if isinstance(content, str):
|
|
1509
|
+
# Basic text content validation
|
|
1510
|
+
if len(content) > 50 * 1024 * 1024: # 50MB limit
|
|
1511
|
+
raise ValueError("Content exceeds maximum size limit (50MB)")
|
|
1512
|
+
|
|
1513
|
+
# Check for suspicious patterns (basic security)
|
|
1514
|
+
suspicious_patterns = [
|
|
1515
|
+
"#!/usr/bin/env", # Executable scripts
|
|
1516
|
+
"eval(", # Code evaluation
|
|
1517
|
+
"exec(", # Code execution
|
|
1518
|
+
"__import__", # Dynamic imports
|
|
1519
|
+
]
|
|
1520
|
+
|
|
1521
|
+
content_lower = content.lower()
|
|
1522
|
+
for pattern in suspicious_patterns:
|
|
1523
|
+
if pattern in content_lower:
|
|
1524
|
+
logger.warning(
|
|
1525
|
+
f"Potentially suspicious content pattern detected: {pattern}"
|
|
1526
|
+
)
|
|
1527
|
+
|
|
1528
|
+
elif isinstance(content, bytes):
|
|
1529
|
+
# Basic binary content validation
|
|
1530
|
+
if len(content) > 100 * 1024 * 1024: # 100MB limit for binary
|
|
1531
|
+
raise ValueError("Binary content exceeds maximum size limit (100MB)")
|
|
1532
|
+
|
|
1533
|
+
# File-specific validation based on extension
|
|
1534
|
+
path = Path(file_path)
|
|
1535
|
+
if path.suffix.lower() in [".py", ".js", ".ts", ".sh"]:
|
|
1536
|
+
# Basic syntax check for code files could be added here
|
|
1537
|
+
pass
|
|
1538
|
+
|
|
1539
|
+
def _generate_content_diff(
|
|
1540
|
+
self, current_content: str, new_content: str, file_path: str
|
|
1541
|
+
) -> str:
|
|
1542
|
+
"""Generate unified diff between current and new content."""
|
|
1543
|
+
import difflib
|
|
1544
|
+
|
|
1545
|
+
current_lines = current_content.splitlines(keepends=True)
|
|
1546
|
+
new_lines = new_content.splitlines(keepends=True)
|
|
1547
|
+
|
|
1548
|
+
diff_lines = list(
|
|
1549
|
+
difflib.unified_diff(
|
|
1550
|
+
current_lines,
|
|
1551
|
+
new_lines,
|
|
1552
|
+
fromfile=f"{file_path} (current)",
|
|
1553
|
+
tofile=f"{file_path} (new)",
|
|
1554
|
+
lineterm="",
|
|
1555
|
+
)
|
|
1556
|
+
)
|
|
1557
|
+
|
|
1558
|
+
return "".join(diff_lines)
|
|
1559
|
+
|
|
1560
|
+
async def preview_file_modification(
|
|
1561
|
+
self,
|
|
1562
|
+
path: Union[str, Path],
|
|
1563
|
+
new_content: Union[str, bytes],
|
|
1564
|
+
encoding: str = "utf-8",
|
|
1565
|
+
) -> Dict[str, Any]:
|
|
1566
|
+
"""
|
|
1567
|
+
Preview file modification without making changes.
|
|
1568
|
+
|
|
1569
|
+
Args:
|
|
1570
|
+
path: Path to the file
|
|
1571
|
+
new_content: Proposed new content
|
|
1572
|
+
encoding: File encoding for text files
|
|
1573
|
+
|
|
1574
|
+
Returns:
|
|
1575
|
+
Dict with preview information including diff
|
|
1576
|
+
"""
|
|
1577
|
+
path = Path(path).resolve()
|
|
1578
|
+
|
|
1579
|
+
# Read current content if file exists
|
|
1580
|
+
current_content = None
|
|
1581
|
+
file_exists = await aiofiles.os.path.exists(path)
|
|
1582
|
+
|
|
1583
|
+
if file_exists:
|
|
1584
|
+
try:
|
|
1585
|
+
current_content = await self.read_file(path, encoding=encoding)
|
|
1586
|
+
except Exception as e:
|
|
1587
|
+
current_content = f"<Error reading file: {str(e)}>"
|
|
1588
|
+
|
|
1589
|
+
# Prepare preview data
|
|
1590
|
+
preview = {
|
|
1591
|
+
"file_path": str(path),
|
|
1592
|
+
"file_exists": file_exists,
|
|
1593
|
+
"operation_type": "create" if not file_exists else "modify",
|
|
1594
|
+
"current_content": current_content,
|
|
1595
|
+
"new_content": (
|
|
1596
|
+
new_content
|
|
1597
|
+
if isinstance(new_content, str)
|
|
1598
|
+
else new_content.decode(encoding, errors="ignore")
|
|
1599
|
+
),
|
|
1600
|
+
"current_size": len(current_content) if current_content else 0,
|
|
1601
|
+
"new_size": (
|
|
1602
|
+
len(new_content)
|
|
1603
|
+
if isinstance(new_content, str)
|
|
1604
|
+
else len(new_content.decode(encoding, errors="ignore"))
|
|
1605
|
+
),
|
|
1606
|
+
"encoding": encoding,
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
# Generate diff if both contents exist
|
|
1610
|
+
if current_content:
|
|
1611
|
+
new_content_str = (
|
|
1612
|
+
new_content
|
|
1613
|
+
if isinstance(new_content, str)
|
|
1614
|
+
else new_content.decode(encoding, errors="ignore")
|
|
1615
|
+
)
|
|
1616
|
+
preview["diff"] = self._generate_content_diff(
|
|
1617
|
+
current_content, new_content_str, str(path)
|
|
1618
|
+
)
|
|
1619
|
+
preview["has_changes"] = current_content != new_content_str
|
|
1620
|
+
else:
|
|
1621
|
+
preview["diff"] = None
|
|
1622
|
+
preview["has_changes"] = True # New file is always a change
|
|
1623
|
+
|
|
1624
|
+
return preview
|
|
1625
|
+
|
|
1626
|
+
# Directory awareness methods
|
|
1627
|
+
|
|
1628
|
+
def get_current_working_directory(self) -> Path:
|
|
1629
|
+
"""Get the current working directory."""
|
|
1630
|
+
return Path.cwd()
|
|
1631
|
+
|
|
1632
|
+
async def is_git_repository(self, path: Optional[Union[str, Path]] = None) -> bool:
|
|
1633
|
+
"""Check if the given path (or current directory) is a Git repository."""
|
|
1634
|
+
|
|
1635
|
+
check_path = (
|
|
1636
|
+
Path(path).resolve() if path else self.get_current_working_directory()
|
|
1637
|
+
)
|
|
1638
|
+
|
|
1639
|
+
# Check for .git directory
|
|
1640
|
+
git_dir = check_path / ".git"
|
|
1641
|
+
if git_dir.exists() and git_dir.is_dir():
|
|
1642
|
+
return True
|
|
1643
|
+
|
|
1644
|
+
# Check if we're inside a git repository by looking up the directory tree
|
|
1645
|
+
current = check_path
|
|
1646
|
+
while current != current.parent: # Stop at filesystem root
|
|
1647
|
+
git_dir = current / ".git"
|
|
1648
|
+
if git_dir.exists() and git_dir.is_dir():
|
|
1649
|
+
return True
|
|
1650
|
+
current = current.parent
|
|
1651
|
+
|
|
1652
|
+
return False
|
|
1653
|
+
|
|
1654
|
+
async def get_git_repository_root(
|
|
1655
|
+
self, path: Optional[Union[str, Path]] = None
|
|
1656
|
+
) -> Optional[Path]:
|
|
1657
|
+
"""Get the root directory of the Git repository, if any."""
|
|
1658
|
+
|
|
1659
|
+
check_path = (
|
|
1660
|
+
Path(path).resolve() if path else self.get_current_working_directory()
|
|
1661
|
+
)
|
|
1662
|
+
|
|
1663
|
+
# Look up the directory tree for .git directory
|
|
1664
|
+
current = check_path
|
|
1665
|
+
while current != current.parent: # Stop at filesystem root
|
|
1666
|
+
git_dir = current / ".git"
|
|
1667
|
+
if git_dir.exists() and git_dir.is_dir():
|
|
1668
|
+
return current
|
|
1669
|
+
current = current.parent
|
|
1670
|
+
|
|
1671
|
+
return None
|
|
1672
|
+
|
|
1673
|
+
async def get_directory_context(
|
|
1674
|
+
self, path: Optional[Union[str, Path]] = None
|
|
1675
|
+
) -> Dict[str, Any]:
|
|
1676
|
+
"""Get comprehensive directory context including working directory and repository status."""
|
|
1677
|
+
|
|
1678
|
+
target_path = (
|
|
1679
|
+
Path(path).resolve() if path else self.get_current_working_directory()
|
|
1680
|
+
)
|
|
1681
|
+
|
|
1682
|
+
context = {
|
|
1683
|
+
"current_working_directory": str(target_path),
|
|
1684
|
+
"is_git_repository": await self.is_git_repository(target_path),
|
|
1685
|
+
"git_repository_root": None,
|
|
1686
|
+
"relative_to_repo_root": None,
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
# If it's a git repository, get additional git context
|
|
1690
|
+
if context["is_git_repository"]:
|
|
1691
|
+
repo_root = await self.get_git_repository_root(target_path)
|
|
1692
|
+
if repo_root:
|
|
1693
|
+
context["git_repository_root"] = str(repo_root)
|
|
1694
|
+
try:
|
|
1695
|
+
context["relative_to_repo_root"] = str(
|
|
1696
|
+
target_path.relative_to(repo_root)
|
|
1697
|
+
)
|
|
1698
|
+
except ValueError:
|
|
1699
|
+
# Path is not relative to repo root (shouldn't happen, but just in case)
|
|
1700
|
+
context["relative_to_repo_root"] = str(target_path)
|
|
1701
|
+
|
|
1702
|
+
return context
|
|
1703
|
+
|
|
1704
|
+
async def cleanup(self) -> None:
|
|
1705
|
+
"""Cleanup resources and temporary files."""
|
|
1706
|
+
|
|
1707
|
+
# Clean up any remaining temporary files
|
|
1708
|
+
for operation in self.active_operations.values():
|
|
1709
|
+
temp_path = operation.get("temp_path")
|
|
1710
|
+
if temp_path and await aiofiles.os.path.exists(temp_path):
|
|
1711
|
+
try:
|
|
1712
|
+
await aiofiles.os.remove(temp_path)
|
|
1713
|
+
except:
|
|
1714
|
+
pass
|
|
1715
|
+
|
|
1716
|
+
self.active_operations.clear()
|
|
1717
|
+
|
|
1718
|
+
# Additional methods for test compatibility
|
|
1719
|
+
|
|
1720
|
+
async def create_file(
|
|
1721
|
+
self,
|
|
1722
|
+
path: Union[str, Path],
|
|
1723
|
+
content: Union[str, bytes] = "",
|
|
1724
|
+
encoding: str = "utf-8",
|
|
1725
|
+
**kwargs,
|
|
1726
|
+
) -> Dict[str, Any]:
|
|
1727
|
+
"""Create a new file with the given content (test compatibility wrapper)."""
|
|
1728
|
+
return await self.write_file(
|
|
1729
|
+
path=path, content=content, encoding=encoding, **kwargs
|
|
1730
|
+
)
|
|
1731
|
+
|
|
1732
|
+
async def modify_file(
|
|
1733
|
+
self,
|
|
1734
|
+
path: Union[str, Path],
|
|
1735
|
+
content: Union[str, bytes],
|
|
1736
|
+
encoding: str = "utf-8",
|
|
1737
|
+
**kwargs,
|
|
1738
|
+
) -> Dict[str, Any]:
|
|
1739
|
+
"""Modify an existing file with new content (test compatibility wrapper)."""
|
|
1740
|
+
return await self.write_file(
|
|
1741
|
+
path=path, content=content, encoding=encoding, **kwargs
|
|
1742
|
+
)
|
|
1743
|
+
|
|
1744
|
+
async def execute_operation(self, operation: Operation) -> OperationResult:
|
|
1745
|
+
"""Execute a file system operation (interface compatibility)."""
|
|
1746
|
+
|
|
1747
|
+
try:
|
|
1748
|
+
if operation.type == OperationType.FILE_READ:
|
|
1749
|
+
result = await self.read_file(operation.data["path"])
|
|
1750
|
+
return OperationResult(success=True, data=result)
|
|
1751
|
+
elif operation.type == OperationType.FILE_WRITE:
|
|
1752
|
+
result = await self.write_file(
|
|
1753
|
+
path=operation.data["path"],
|
|
1754
|
+
content=operation.data["content"],
|
|
1755
|
+
encoding=operation.data.get("encoding", "utf-8"),
|
|
1756
|
+
)
|
|
1757
|
+
return OperationResult(success=result["success"], data=result)
|
|
1758
|
+
elif operation.type == OperationType.FILE_DELETE:
|
|
1759
|
+
result = await self.delete_file(
|
|
1760
|
+
path=operation.data["path"],
|
|
1761
|
+
backup=operation.data.get("backup", True),
|
|
1762
|
+
)
|
|
1763
|
+
return OperationResult(success=result["success"], data=result)
|
|
1764
|
+
else:
|
|
1765
|
+
return OperationResult(
|
|
1766
|
+
success=False,
|
|
1767
|
+
error=f"Unsupported operation type: {operation.type}",
|
|
1768
|
+
)
|
|
1769
|
+
except Exception as e:
|
|
1770
|
+
return OperationResult(success=False, error=str(e))
|
|
1771
|
+
|
|
1772
|
+
async def preview_operation(self, operation: Operation) -> str:
|
|
1773
|
+
"""Generate a preview of what the operation will do (interface compatibility)."""
|
|
1774
|
+
try:
|
|
1775
|
+
if operation.type == OperationType.FILE_READ:
|
|
1776
|
+
return f"Read file: {operation.data['path']}"
|
|
1777
|
+
elif operation.type == OperationType.FILE_WRITE:
|
|
1778
|
+
path = operation.data["path"]
|
|
1779
|
+
exists = await aiofiles.os.path.exists(path)
|
|
1780
|
+
action = "Update" if exists else "Create"
|
|
1781
|
+
content_preview = str(operation.data["content"])[:100]
|
|
1782
|
+
if len(content_preview) < len(str(operation.data["content"])):
|
|
1783
|
+
content_preview += "..."
|
|
1784
|
+
return f"{action} file: {path}\nContent preview: {content_preview}"
|
|
1785
|
+
elif operation.type == OperationType.FILE_DELETE:
|
|
1786
|
+
return f"Delete file: {operation.data['path']}"
|
|
1787
|
+
else:
|
|
1788
|
+
return f"Unknown operation: {operation.type}"
|
|
1789
|
+
except Exception as e:
|
|
1790
|
+
return f"Preview error: {str(e)}"
|