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.
Files changed (126) hide show
  1. omnimancer/__init__.py +42 -0
  2. omnimancer/__main__.py +9 -0
  3. omnimancer/cli/__init__.py +8 -0
  4. omnimancer/cli/agent_manager.py +826 -0
  5. omnimancer/cli/agent_wizard.py +944 -0
  6. omnimancer/cli/approval_formatter.py +734 -0
  7. omnimancer/cli/approval_integration.py +734 -0
  8. omnimancer/cli/approval_prompt.py +1286 -0
  9. omnimancer/cli/batch_approval_display.py +617 -0
  10. omnimancer/cli/batch_approval_filters.py +729 -0
  11. omnimancer/cli/batch_operation_monitor.py +775 -0
  12. omnimancer/cli/commands/performance_cmd.py +624 -0
  13. omnimancer/cli/commands.py +757 -0
  14. omnimancer/cli/completion_handler.py +281 -0
  15. omnimancer/cli/config_setup_wizard.py +783 -0
  16. omnimancer/cli/dynamic_commands.py +371 -0
  17. omnimancer/cli/handlers/__init__.py +18 -0
  18. omnimancer/cli/handlers/agent_handler.py +865 -0
  19. omnimancer/cli/handlers/agent_persona_handler.py +1048 -0
  20. omnimancer/cli/handlers/custom_agent_handler.py +352 -0
  21. omnimancer/cli/handlers/permissions_handler.py +1609 -0
  22. omnimancer/cli/interface.py +6695 -0
  23. omnimancer/cli/performance_dashboard.py +612 -0
  24. omnimancer/core/__init__.py +34 -0
  25. omnimancer/core/agent/__init__.py +256 -0
  26. omnimancer/core/agent/agent_config.py +715 -0
  27. omnimancer/core/agent/agent_switcher.py +668 -0
  28. omnimancer/core/agent/approval_context.py +460 -0
  29. omnimancer/core/agent/approval_dialog.py +902 -0
  30. omnimancer/core/agent/approval_interface.py +628 -0
  31. omnimancer/core/agent/approval_manager.py +760 -0
  32. omnimancer/core/agent/cli_integration.py +470 -0
  33. omnimancer/core/agent/config.py +845 -0
  34. omnimancer/core/agent/custom_agent_integration.py +654 -0
  35. omnimancer/core/agent/diff_renderer.py +706 -0
  36. omnimancer/core/agent/file_content_display.py +503 -0
  37. omnimancer/core/agent/file_modification_workflow.py +658 -0
  38. omnimancer/core/agent/file_system_manager.py +1790 -0
  39. omnimancer/core/agent/input_handler.py +614 -0
  40. omnimancer/core/agent/metrics_collector.py +771 -0
  41. omnimancer/core/agent/optimization_engine.py +729 -0
  42. omnimancer/core/agent/performance_monitor.py +717 -0
  43. omnimancer/core/agent/persona.py +870 -0
  44. omnimancer/core/agent/persona_validator.py +672 -0
  45. omnimancer/core/agent/program_executor.py +748 -0
  46. omnimancer/core/agent/proposed_changes_integration.py +663 -0
  47. omnimancer/core/agent/read_before_write_errors.py +492 -0
  48. omnimancer/core/agent/read_before_write_ui.py +647 -0
  49. omnimancer/core/agent/rich_renderer.py +763 -0
  50. omnimancer/core/agent/status_config.py +238 -0
  51. omnimancer/core/agent/status_core.py +446 -0
  52. omnimancer/core/agent/status_integrated_engine.py +540 -0
  53. omnimancer/core/agent/status_manager.py +878 -0
  54. omnimancer/core/agent/status_ui_components.py +535 -0
  55. omnimancer/core/agent/template_applicator.py +548 -0
  56. omnimancer/core/agent/terminal_status_listener.py +394 -0
  57. omnimancer/core/agent/token_tracker.py +679 -0
  58. omnimancer/core/agent/types.py +60 -0
  59. omnimancer/core/agent/web_client.py +745 -0
  60. omnimancer/core/agent/workflow_orchestrator.py +826 -0
  61. omnimancer/core/agent_engine.py +1625 -0
  62. omnimancer/core/agent_mode_manager.py +706 -0
  63. omnimancer/core/agent_progress_ui.py +545 -0
  64. omnimancer/core/chat_manager.py +141 -0
  65. omnimancer/core/config_manager.py +2199 -0
  66. omnimancer/core/config_migration.py +772 -0
  67. omnimancer/core/config_provider.py +1974 -0
  68. omnimancer/core/config_security_validator.py +393 -0
  69. omnimancer/core/config_validator.py +768 -0
  70. omnimancer/core/conversation_manager.py +352 -0
  71. omnimancer/core/engine.py +787 -0
  72. omnimancer/core/enhanced_input.py +523 -0
  73. omnimancer/core/env_loader.py +83 -0
  74. omnimancer/core/fallback_manager.py +568 -0
  75. omnimancer/core/health_monitor.py +462 -0
  76. omnimancer/core/history_manager.py +462 -0
  77. omnimancer/core/mcp_integration_layer.py +878 -0
  78. omnimancer/core/models.py +1889 -0
  79. omnimancer/core/provider_capabilities.py +403 -0
  80. omnimancer/core/provider_initializer.py +450 -0
  81. omnimancer/core/provider_registry.py +432 -0
  82. omnimancer/core/security/__init__.py +16 -0
  83. omnimancer/core/security/approval_workflow.py +406 -0
  84. omnimancer/core/security/audit_logger.py +511 -0
  85. omnimancer/core/security/permission_controller.py +511 -0
  86. omnimancer/core/security/sandbox_manager.py +379 -0
  87. omnimancer/core/security/security_manager.py +530 -0
  88. omnimancer/core/setup_wizard.py +345 -0
  89. omnimancer/core/setup_wizard_core.py +449 -0
  90. omnimancer/core/setup_wizard_guide_generator.py +376 -0
  91. omnimancer/core/setup_wizard_provider_setup.py +454 -0
  92. omnimancer/core/setup_wizard_ui.py +317 -0
  93. omnimancer/core/setup_wizard_validation.py +371 -0
  94. omnimancer/core/signal_handler.py +174 -0
  95. omnimancer/mcp/__init__.py +11 -0
  96. omnimancer/mcp/client.py +725 -0
  97. omnimancer/mcp/manager.py +649 -0
  98. omnimancer/providers/__init__.py +24 -0
  99. omnimancer/providers/azure.py +608 -0
  100. omnimancer/providers/base.py +262 -0
  101. omnimancer/providers/bedrock.py +711 -0
  102. omnimancer/providers/claude.py +426 -0
  103. omnimancer/providers/claude_code.py +406 -0
  104. omnimancer/providers/cohere.py +412 -0
  105. omnimancer/providers/factory.py +988 -0
  106. omnimancer/providers/gemini.py +708 -0
  107. omnimancer/providers/mistral.py +554 -0
  108. omnimancer/providers/ollama.py +510 -0
  109. omnimancer/providers/openai.py +362 -0
  110. omnimancer/providers/openrouter.py +709 -0
  111. omnimancer/providers/perplexity.py +476 -0
  112. omnimancer/providers/vertex.py +617 -0
  113. omnimancer/providers/xai.py +484 -0
  114. omnimancer/ui/__init__.py +6 -0
  115. omnimancer/ui/cancellation_handler.py +235 -0
  116. omnimancer/ui/progress_indicator.py +318 -0
  117. omnimancer/utils/__init__.py +22 -0
  118. omnimancer/utils/error_handler.py +589 -0
  119. omnimancer/utils/errors.py +667 -0
  120. omnimancer/utils/retry.py +238 -0
  121. omnimancer_cli-0.1.0.dist-info/METADATA +413 -0
  122. omnimancer_cli-0.1.0.dist-info/RECORD +126 -0
  123. omnimancer_cli-0.1.0.dist-info/WHEEL +5 -0
  124. omnimancer_cli-0.1.0.dist-info/entry_points.txt +4 -0
  125. omnimancer_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  126. omnimancer_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,760 @@
1
+ """
2
+ Enhanced User Approval and Preview System for Omnimancer Agent.
3
+
4
+ This module provides comprehensive approval workflows with change preview,
5
+ diff visualization, batch approval capabilities, and integration with the
6
+ existing security framework.
7
+ """
8
+
9
+ import difflib
10
+ import json
11
+ import logging
12
+ import uuid
13
+ from dataclasses import dataclass, field
14
+ from datetime import datetime, timedelta
15
+ from enum import Enum
16
+ from pathlib import Path
17
+ from typing import Any, Callable, Dict, List, Optional, Set
18
+
19
+ from ..security.approval_workflow import (
20
+ ApprovalRequest,
21
+ ApprovalStatus,
22
+ ApprovalWorkflow,
23
+ )
24
+ from .types import Operation, OperationType
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ class ChangeType(Enum):
30
+ """Types of changes that can be previewed."""
31
+
32
+ FILE_CREATE = "file_create"
33
+ FILE_MODIFY = "file_modify"
34
+ FILE_DELETE = "file_delete"
35
+ DIRECTORY_CREATE = "directory_create"
36
+ DIRECTORY_DELETE = "directory_delete"
37
+ COMMAND_EXECUTE = "command_execute"
38
+ WEB_REQUEST = "web_request"
39
+ MCP_TOOL_CALL = "mcp_tool_call"
40
+
41
+
42
+ class PreviewFormat(Enum):
43
+ """Format for change previews."""
44
+
45
+ TEXT = "text"
46
+ DIFF = "diff"
47
+ JSON = "json"
48
+ HTML = "html"
49
+
50
+
51
+ @dataclass
52
+ class ChangePreview:
53
+ """Represents a preview of a change."""
54
+
55
+ change_type: ChangeType
56
+ description: str
57
+ current_state: Optional[str] = None
58
+ proposed_state: Optional[str] = None
59
+ diff: Optional[str] = None
60
+ metadata: Dict[str, Any] = field(default_factory=dict)
61
+ risk_assessment: Optional[str] = None
62
+ reversible: bool = False
63
+
64
+ def generate_diff(self) -> str:
65
+ """Generate unified diff between current and proposed states."""
66
+ if not self.current_state or not self.proposed_state:
67
+ return "No diff available - new file or deletion"
68
+
69
+ current_lines = self.current_state.splitlines(keepends=True)
70
+ proposed_lines = self.proposed_state.splitlines(keepends=True)
71
+
72
+ diff_lines = list(
73
+ difflib.unified_diff(
74
+ current_lines,
75
+ proposed_lines,
76
+ fromfile=f"{self.metadata.get('path', 'current')}",
77
+ tofile=f"{self.metadata.get('path', 'proposed')}",
78
+ lineterm="",
79
+ )
80
+ )
81
+
82
+ return "".join(diff_lines)
83
+
84
+ def format_preview(self, format_type: PreviewFormat = PreviewFormat.TEXT) -> str:
85
+ """Format the preview for display."""
86
+ if format_type == PreviewFormat.DIFF:
87
+ if not self.diff:
88
+ self.diff = self.generate_diff()
89
+ return self.diff
90
+ elif format_type == PreviewFormat.JSON:
91
+ return json.dumps(
92
+ {
93
+ "type": self.change_type.value,
94
+ "description": self.description,
95
+ "metadata": self.metadata,
96
+ "risk_assessment": self.risk_assessment,
97
+ "reversible": self.reversible,
98
+ },
99
+ indent=2,
100
+ )
101
+ elif format_type == PreviewFormat.HTML:
102
+ return self._format_html_preview()
103
+ else:
104
+ return self._format_text_preview()
105
+
106
+ def _format_text_preview(self) -> str:
107
+ """Format as plain text preview."""
108
+ lines = [
109
+ f"Change Type: {self.change_type.value}",
110
+ f"Description: {self.description}",
111
+ ]
112
+
113
+ if self.risk_assessment:
114
+ lines.append(f"Risk Assessment: {self.risk_assessment}")
115
+
116
+ lines.append(f"Reversible: {'Yes' if self.reversible else 'No'}")
117
+
118
+ if self.metadata:
119
+ lines.append("Metadata:")
120
+ for key, value in self.metadata.items():
121
+ lines.append(f" {key}: {value}")
122
+
123
+ if self.diff:
124
+ lines.extend(["", "Changes:", self.diff])
125
+
126
+ return "\n".join(lines)
127
+
128
+ def _format_html_preview(self) -> str:
129
+ """Format as HTML preview."""
130
+ html_parts = [
131
+ "<div class='change-preview'>",
132
+ f"<h3>{self.change_type.value.replace('_', ' ').title()}</h3>",
133
+ f"<p><strong>Description:</strong> {self.description}</p>",
134
+ ]
135
+
136
+ if self.risk_assessment:
137
+ html_parts.append(
138
+ f"<p><strong>Risk:</strong> <span class='risk'>{self.risk_assessment}</span></p>"
139
+ )
140
+
141
+ html_parts.append(
142
+ f"<p><strong>Reversible:</strong> {'Yes' if self.reversible else 'No'}</p>"
143
+ )
144
+
145
+ if self.diff:
146
+ html_parts.extend(
147
+ [
148
+ "<div class='diff-container'>",
149
+ "<pre class='diff'>",
150
+ self.diff.replace("<", "&lt;").replace(">", "&gt;"),
151
+ "</pre>",
152
+ "</div>",
153
+ ]
154
+ )
155
+
156
+ html_parts.append("</div>")
157
+ return "\n".join(html_parts)
158
+
159
+
160
+ @dataclass
161
+ class BatchApprovalRequest:
162
+ """Represents a batch of operations requiring approval."""
163
+
164
+ id: str = field(default_factory=lambda: str(uuid.uuid4()))
165
+ operations: List[Operation] = field(default_factory=list)
166
+ previews: List[ChangePreview] = field(default_factory=list)
167
+ status: ApprovalStatus = ApprovalStatus.PENDING
168
+ created_at: datetime = field(default_factory=datetime.now)
169
+ expires_at: Optional[datetime] = None
170
+ approver: Optional[str] = None
171
+ approved_at: Optional[datetime] = None
172
+ denial_reason: Optional[str] = None
173
+ approved_operations: Set[int] = field(
174
+ default_factory=set
175
+ ) # Indices of approved operations
176
+
177
+ def is_expired(self) -> bool:
178
+ """Check if the batch request has expired."""
179
+ return self.expires_at and datetime.now() > self.expires_at
180
+
181
+ def get_approval_summary(self) -> Dict[str, Any]:
182
+ """Get summary of approval status."""
183
+ total = len(self.operations)
184
+ approved = len(self.approved_operations)
185
+ return {
186
+ "total_operations": total,
187
+ "approved_operations": approved,
188
+ "pending_operations": total - approved,
189
+ "approval_rate": approved / total if total > 0 else 0,
190
+ "all_approved": approved == total,
191
+ "partially_approved": 0 < approved < total,
192
+ }
193
+
194
+
195
+ class EnhancedApprovalManager:
196
+ """
197
+ Enhanced approval manager with preview, diff visualization, and batch approval.
198
+
199
+ This class bridges the basic ApprovalManager functionality with the advanced
200
+ ApprovalWorkflow security system, adding comprehensive preview capabilities
201
+ and batch approval support.
202
+ """
203
+
204
+ def __init__(
205
+ self,
206
+ approval_workflow: Optional[ApprovalWorkflow] = None,
207
+ default_timeout_minutes: int = 30,
208
+ enable_batch_approval: bool = True,
209
+ max_batch_size: int = 10,
210
+ ):
211
+ """
212
+ Initialize enhanced approval manager.
213
+
214
+ Args:
215
+ approval_workflow: Optional existing approval workflow instance
216
+ default_timeout_minutes: Default timeout for approval requests
217
+ enable_batch_approval: Whether to enable batch approval functionality
218
+ max_batch_size: Maximum number of operations in a batch
219
+ """
220
+ self.approval_workflow = approval_workflow or ApprovalWorkflow()
221
+ self.default_timeout_minutes = default_timeout_minutes
222
+ self.enable_batch_approval = enable_batch_approval
223
+ self.max_batch_size = max_batch_size
224
+
225
+ # Batch approval management
226
+ self.pending_batches: Dict[str, BatchApprovalRequest] = {}
227
+ self.completed_batches: Dict[str, BatchApprovalRequest] = {}
228
+
229
+ # Preview generators for different operation types
230
+ self.preview_generators: Dict[OperationType, Callable] = {
231
+ OperationType.FILE_READ: self._generate_file_read_preview,
232
+ OperationType.FILE_WRITE: self._generate_file_write_preview,
233
+ OperationType.FILE_DELETE: self._generate_file_delete_preview,
234
+ OperationType.DIRECTORY_CREATE: self._generate_directory_create_preview,
235
+ OperationType.DIRECTORY_DELETE: self._generate_directory_delete_preview,
236
+ OperationType.COMMAND_EXECUTE: self._generate_command_preview,
237
+ OperationType.WEB_REQUEST: self._generate_web_request_preview,
238
+ OperationType.MCP_TOOL_CALL: self._generate_mcp_tool_preview,
239
+ }
240
+
241
+ # User interaction callbacks
242
+ self.approval_callback: Optional[Callable] = None
243
+ self.batch_approval_callback: Optional[Callable] = None
244
+
245
+ # Approval history for audit trail
246
+ self.approval_history: List[Dict[str, Any]] = []
247
+
248
+ def set_approval_callback(self, callback: Callable):
249
+ """Set callback for single operation approval."""
250
+ self.approval_callback = callback
251
+
252
+ def set_batch_approval_callback(self, callback: Callable):
253
+ """Set callback for batch operation approval."""
254
+ self.batch_approval_callback = callback
255
+
256
+ async def request_single_approval(self, operation: Operation) -> bool:
257
+ """
258
+ Request approval for a single operation with preview.
259
+
260
+ Args:
261
+ operation: Operation requiring approval
262
+
263
+ Returns:
264
+ True if approved, False if denied
265
+ """
266
+ try:
267
+ # Generate preview
268
+ preview = await self.generate_operation_preview(operation)
269
+ operation.preview = preview.format_preview()
270
+
271
+ # Create approval request through security workflow
272
+ approval_request = await self.approval_workflow.request_approval(
273
+ operation_type=operation.type.value,
274
+ description=operation.description,
275
+ metadata={
276
+ "operation_data": operation.data,
277
+ "preview": preview.format_preview(PreviewFormat.JSON),
278
+ "reversible": operation.reversible,
279
+ },
280
+ timeout_minutes=self.default_timeout_minutes,
281
+ )
282
+
283
+ # If auto-approved (low risk), return immediately
284
+ if approval_request.status == ApprovalStatus.APPROVED:
285
+ self._record_approval_history(operation, approval_request, preview)
286
+ return True
287
+
288
+ # Request user approval through callback
289
+ if self.approval_callback:
290
+ user_approved = await self.approval_callback(
291
+ {
292
+ "operation": operation,
293
+ "preview": preview,
294
+ "approval_request": approval_request,
295
+ "risk_level": approval_request.risk_level,
296
+ }
297
+ )
298
+
299
+ if user_approved:
300
+ self.approval_workflow.approve_request(
301
+ approval_request.id, "user", "User approved through UI"
302
+ )
303
+ self._record_approval_history(operation, approval_request, preview)
304
+ return True
305
+ else:
306
+ self.approval_workflow.deny_request(
307
+ approval_request.id, "user", "User denied through UI"
308
+ )
309
+ self._record_approval_history(
310
+ operation, approval_request, preview, approved=False
311
+ )
312
+ return False
313
+
314
+ # No callback available, default to deny
315
+ logger.warning(f"No approval callback set for operation: {operation.type}")
316
+ return False
317
+
318
+ except Exception as e:
319
+ logger.error(f"Error in approval request: {e}")
320
+ return False
321
+
322
+ async def request_batch_approval(
323
+ self, operations: List[Operation]
324
+ ) -> BatchApprovalRequest:
325
+ """
326
+ Request approval for a batch of operations.
327
+
328
+ Args:
329
+ operations: List of operations requiring approval
330
+
331
+ Returns:
332
+ BatchApprovalRequest with approval status
333
+ """
334
+ if not self.enable_batch_approval:
335
+ raise ValueError("Batch approval is disabled")
336
+
337
+ if len(operations) > self.max_batch_size:
338
+ raise ValueError(
339
+ f"Batch size {len(operations)} exceeds maximum {self.max_batch_size}"
340
+ )
341
+
342
+ # Generate previews for all operations
343
+ previews = []
344
+ for operation in operations:
345
+ try:
346
+ preview = await self.generate_operation_preview(operation)
347
+ previews.append(preview)
348
+ except Exception as e:
349
+ logger.error(f"Error generating preview for {operation.type}: {e}")
350
+ # Create basic preview on error
351
+ previews.append(
352
+ ChangePreview(
353
+ change_type=ChangeType(operation.type.value),
354
+ description=f"Error generating preview: {str(e)}",
355
+ metadata={"error": str(e)},
356
+ )
357
+ )
358
+
359
+ # Create batch request
360
+ batch_request = BatchApprovalRequest(
361
+ operations=operations,
362
+ previews=previews,
363
+ expires_at=datetime.now() + timedelta(minutes=self.default_timeout_minutes),
364
+ )
365
+
366
+ self.pending_batches[batch_request.id] = batch_request
367
+
368
+ # Request user approval through batch callback
369
+ if self.batch_approval_callback:
370
+ try:
371
+ approval_result = await self.batch_approval_callback(batch_request)
372
+ if approval_result:
373
+ self._process_batch_approval_result(batch_request, approval_result)
374
+ except Exception as e:
375
+ logger.error(f"Error in batch approval callback: {e}")
376
+
377
+ return batch_request
378
+
379
+ def _process_batch_approval_result(
380
+ self,
381
+ batch_request: BatchApprovalRequest,
382
+ approval_result: Dict[str, Any],
383
+ ):
384
+ """Process the result of batch approval from user."""
385
+ if approval_result.get("approve_all"):
386
+ batch_request.approved_operations = set(
387
+ range(len(batch_request.operations))
388
+ )
389
+ batch_request.status = ApprovalStatus.APPROVED
390
+ elif approval_result.get("deny_all"):
391
+ batch_request.status = ApprovalStatus.DENIED
392
+ batch_request.denial_reason = approval_result.get(
393
+ "reason", "User denied all operations"
394
+ )
395
+ elif "approved_indices" in approval_result:
396
+ batch_request.approved_operations = set(approval_result["approved_indices"])
397
+ if len(batch_request.approved_operations) == len(batch_request.operations):
398
+ batch_request.status = ApprovalStatus.APPROVED
399
+ elif len(batch_request.approved_operations) > 0:
400
+ batch_request.status = (
401
+ ApprovalStatus.APPROVED
402
+ ) # Partial approval still counts as approved
403
+
404
+ batch_request.approver = "user"
405
+ batch_request.approved_at = datetime.now()
406
+
407
+ # Move to completed batches
408
+ self.completed_batches[batch_request.id] = batch_request
409
+ if batch_request.id in self.pending_batches:
410
+ del self.pending_batches[batch_request.id]
411
+
412
+ async def generate_operation_preview(self, operation: Operation) -> ChangePreview:
413
+ """
414
+ Generate a comprehensive preview for an operation.
415
+
416
+ Args:
417
+ operation: Operation to generate preview for
418
+
419
+ Returns:
420
+ ChangePreview with diff visualization and metadata
421
+ """
422
+ generator = self.preview_generators.get(operation.type)
423
+ if generator:
424
+ return await generator(operation)
425
+ else:
426
+ # Fallback generic preview
427
+ return ChangePreview(
428
+ change_type=ChangeType(operation.type.value),
429
+ description=operation.description,
430
+ metadata=operation.data,
431
+ reversible=operation.reversible,
432
+ )
433
+
434
+ async def _generate_file_read_preview(self, operation: Operation) -> ChangePreview:
435
+ """Generate preview for file read operation."""
436
+ file_path = operation.data.get("path", "unknown")
437
+ return ChangePreview(
438
+ change_type=ChangeType.FILE_MODIFY,
439
+ description=f"Read file: {file_path}",
440
+ metadata={"path": file_path, "operation": "read"},
441
+ risk_assessment="Low - Read-only operation",
442
+ reversible=False,
443
+ )
444
+
445
+ async def _generate_file_write_preview(self, operation: Operation) -> ChangePreview:
446
+ """Generate preview for file write operation."""
447
+ file_path = operation.data.get("path", "unknown")
448
+ new_content = operation.data.get("content", "")
449
+
450
+ # Try to read current content for diff
451
+ current_content = None
452
+ try:
453
+ path = Path(file_path)
454
+ if path.exists() and path.is_file():
455
+ current_content = path.read_text(encoding="utf-8")
456
+ except Exception as e:
457
+ logger.debug(f"Could not read current content of {file_path}: {e}")
458
+
459
+ preview = ChangePreview(
460
+ change_type=(
461
+ ChangeType.FILE_CREATE
462
+ if not current_content
463
+ else ChangeType.FILE_MODIFY
464
+ ),
465
+ description=f"Write to file: {file_path}",
466
+ current_state=current_content,
467
+ proposed_state=new_content,
468
+ metadata={
469
+ "path": file_path,
470
+ "content_length": len(new_content),
471
+ "operation": "write",
472
+ },
473
+ risk_assessment=self._assess_file_write_risk(file_path, new_content),
474
+ reversible=bool(current_content), # Reversible if we have backup content
475
+ )
476
+
477
+ # Generate diff if we have both states
478
+ if current_content is not None:
479
+ preview.diff = preview.generate_diff()
480
+
481
+ return preview
482
+
483
+ async def _generate_file_delete_preview(
484
+ self, operation: Operation
485
+ ) -> ChangePreview:
486
+ """Generate preview for file delete operation."""
487
+ file_path = operation.data.get("path", "unknown")
488
+
489
+ # Try to read current content for backup
490
+ current_content = None
491
+ try:
492
+ path = Path(file_path)
493
+ if path.exists() and path.is_file():
494
+ current_content = path.read_text(encoding="utf-8")
495
+ except Exception as e:
496
+ logger.debug(
497
+ f"Could not read content of {file_path} for deletion preview: {e}"
498
+ )
499
+
500
+ return ChangePreview(
501
+ change_type=ChangeType.FILE_DELETE,
502
+ description=f"Delete file: {file_path}",
503
+ current_state=current_content,
504
+ proposed_state="", # File will be gone
505
+ metadata={
506
+ "path": file_path,
507
+ "file_size": len(current_content) if current_content else 0,
508
+ "operation": "delete",
509
+ },
510
+ risk_assessment=self._assess_file_delete_risk(file_path),
511
+ reversible=bool(current_content),
512
+ )
513
+
514
+ async def _generate_directory_create_preview(
515
+ self, operation: Operation
516
+ ) -> ChangePreview:
517
+ """Generate preview for directory creation."""
518
+ dir_path = operation.data.get("path", "unknown")
519
+ return ChangePreview(
520
+ change_type=ChangeType.DIRECTORY_CREATE,
521
+ description=f"Create directory: {dir_path}",
522
+ metadata={"path": dir_path, "operation": "mkdir"},
523
+ risk_assessment="Low - Directory creation",
524
+ reversible=True,
525
+ )
526
+
527
+ async def _generate_directory_delete_preview(
528
+ self, operation: Operation
529
+ ) -> ChangePreview:
530
+ """Generate preview for directory deletion."""
531
+ dir_path = operation.data.get("path", "unknown")
532
+
533
+ # Check if directory exists and list contents
534
+ contents = []
535
+ try:
536
+ path = Path(dir_path)
537
+ if path.exists() and path.is_dir():
538
+ contents = [str(item) for item in path.iterdir()]
539
+ except Exception as e:
540
+ logger.debug(f"Could not list contents of {dir_path}: {e}")
541
+
542
+ return ChangePreview(
543
+ change_type=ChangeType.DIRECTORY_DELETE,
544
+ description=f"Delete directory: {dir_path}",
545
+ metadata={
546
+ "path": dir_path,
547
+ "contents": contents,
548
+ "item_count": len(contents),
549
+ "operation": "rmdir",
550
+ },
551
+ risk_assessment=self._assess_directory_delete_risk(dir_path, contents),
552
+ reversible=len(contents) == 0, # Only reversible if empty
553
+ )
554
+
555
+ async def _generate_command_preview(self, operation: Operation) -> ChangePreview:
556
+ """Generate preview for command execution."""
557
+ command = operation.data.get("command", "unknown")
558
+ args = operation.data.get("args", [])
559
+ full_command = f"{command} {' '.join(args)}" if args else command
560
+
561
+ return ChangePreview(
562
+ change_type=ChangeType.COMMAND_EXECUTE,
563
+ description=f"Execute command: {full_command}",
564
+ metadata={
565
+ "command": command,
566
+ "args": args,
567
+ "full_command": full_command,
568
+ "working_dir": operation.data.get("working_dir"),
569
+ "operation": "execute",
570
+ },
571
+ risk_assessment=self._assess_command_risk(full_command),
572
+ reversible=False, # Commands are generally not reversible
573
+ )
574
+
575
+ async def _generate_web_request_preview(
576
+ self, operation: Operation
577
+ ) -> ChangePreview:
578
+ """Generate preview for web request."""
579
+ url = operation.data.get("url", "unknown")
580
+ method = operation.data.get("method", "GET")
581
+
582
+ return ChangePreview(
583
+ change_type=ChangeType.WEB_REQUEST,
584
+ description=f"{method} request to: {url}",
585
+ metadata={
586
+ "url": url,
587
+ "method": method,
588
+ "headers": operation.data.get("headers", {}),
589
+ "operation": "web_request",
590
+ },
591
+ risk_assessment=self._assess_web_request_risk(url, method),
592
+ reversible=method.upper() == "GET",
593
+ )
594
+
595
+ async def _generate_mcp_tool_preview(self, operation: Operation) -> ChangePreview:
596
+ """Generate preview for MCP tool call."""
597
+ tool_name = operation.data.get("tool_name", "unknown")
598
+ arguments = operation.data.get("arguments", {})
599
+
600
+ return ChangePreview(
601
+ change_type=ChangeType.MCP_TOOL_CALL,
602
+ description=f"Call MCP tool: {tool_name}",
603
+ metadata={
604
+ "tool_name": tool_name,
605
+ "arguments": arguments,
606
+ "operation": "mcp_call",
607
+ },
608
+ risk_assessment=self._assess_mcp_tool_risk(tool_name, arguments),
609
+ reversible=False, # Tool calls are generally not reversible
610
+ )
611
+
612
+ def _assess_file_write_risk(self, file_path: str, content: str) -> str:
613
+ """Assess risk level for file write operations."""
614
+ path_lower = file_path.lower()
615
+ risk_factors = []
616
+
617
+ if any(
618
+ pattern in path_lower
619
+ for pattern in [".env", "password", "secret", "key", "credential"]
620
+ ):
621
+ risk_factors.append("sensitive file")
622
+
623
+ if any(
624
+ pattern in path_lower for pattern in ["config", "settings", ".ssh", "/etc"]
625
+ ):
626
+ risk_factors.append("configuration file")
627
+
628
+ if len(content) > 100000: # Large files
629
+ risk_factors.append("large file")
630
+
631
+ if not risk_factors:
632
+ return "Low - Standard file write"
633
+ elif len(risk_factors) == 1:
634
+ return f"Medium - {risk_factors[0]}"
635
+ else:
636
+ return f"High - Multiple factors: {', '.join(risk_factors)}"
637
+
638
+ def _assess_file_delete_risk(self, file_path: str) -> str:
639
+ """Assess risk level for file deletion."""
640
+ if any(
641
+ pattern in file_path.lower()
642
+ for pattern in [".env", "config", "settings", "backup"]
643
+ ):
644
+ return "High - Deleting important file"
645
+ return "Medium - File deletion"
646
+
647
+ def _assess_directory_delete_risk(self, dir_path: str, contents: List[str]) -> str:
648
+ """Assess risk level for directory deletion."""
649
+ if len(contents) > 10:
650
+ return "High - Deleting directory with many files"
651
+ elif len(contents) > 0:
652
+ return "Medium - Deleting non-empty directory"
653
+ else:
654
+ return "Low - Deleting empty directory"
655
+
656
+ def _assess_command_risk(self, command: str) -> str:
657
+ """Assess risk level for command execution."""
658
+ command_lower = command.lower()
659
+
660
+ if any(
661
+ dangerous in command_lower for dangerous in ["rm -rf", "sudo", "chmod 777"]
662
+ ):
663
+ return "Critical - Dangerous command"
664
+ elif any(
665
+ risky in command_lower for risky in ["rm", "mv", "cp", "chmod", "chown"]
666
+ ):
667
+ return "High - File system modification"
668
+ elif any(network in command_lower for network in ["curl", "wget", "nc", "ssh"]):
669
+ return "Medium - Network operation"
670
+ else:
671
+ return "Low - Safe command"
672
+
673
+ def _assess_web_request_risk(self, url: str, method: str) -> str:
674
+ """Assess risk level for web requests."""
675
+ if method.upper() in ["POST", "PUT", "DELETE", "PATCH"]:
676
+ return "Medium - Data modification request"
677
+ elif "localhost" in url or "127.0.0.1" in url:
678
+ return "Low - Local request"
679
+ else:
680
+ return "Low - External GET request"
681
+
682
+ def _assess_mcp_tool_risk(self, tool_name: str, arguments: Dict[str, Any]) -> str:
683
+ """Assess risk level for MCP tool calls."""
684
+ if "write" in tool_name.lower() or "delete" in tool_name.lower():
685
+ return "Medium - Potentially modifying tool"
686
+ elif "execute" in tool_name.lower() or "run" in tool_name.lower():
687
+ return "High - Execution tool"
688
+ else:
689
+ return "Low - Query or read tool"
690
+
691
+ def _record_approval_history(
692
+ self,
693
+ operation: Operation,
694
+ approval_request: ApprovalRequest,
695
+ preview: ChangePreview,
696
+ approved: bool = True,
697
+ ):
698
+ """Record approval in history for audit trail."""
699
+ history_entry = {
700
+ "timestamp": datetime.now().isoformat(),
701
+ "operation_type": operation.type.value,
702
+ "operation_description": operation.description,
703
+ "approval_id": approval_request.id,
704
+ "risk_level": approval_request.risk_level.value,
705
+ "approved": approved,
706
+ "approver": approval_request.approver,
707
+ "preview_summary": {
708
+ "change_type": preview.change_type.value,
709
+ "risk_assessment": preview.risk_assessment,
710
+ "reversible": preview.reversible,
711
+ },
712
+ }
713
+
714
+ self.approval_history.append(history_entry)
715
+
716
+ # Keep only last 1000 entries to prevent memory bloat
717
+ if len(self.approval_history) > 1000:
718
+ self.approval_history = self.approval_history[-1000:]
719
+
720
+ def get_approval_statistics(self) -> Dict[str, Any]:
721
+ """Get statistics about approval history."""
722
+ if not self.approval_history:
723
+ return {"total_requests": 0}
724
+
725
+ total = len(self.approval_history)
726
+ approved = sum(1 for entry in self.approval_history if entry["approved"])
727
+
728
+ risk_levels = {}
729
+ operation_types = {}
730
+
731
+ for entry in self.approval_history:
732
+ risk = entry["risk_level"]
733
+ op_type = entry["operation_type"]
734
+
735
+ risk_levels[risk] = risk_levels.get(risk, 0) + 1
736
+ operation_types[op_type] = operation_types.get(op_type, 0) + 1
737
+
738
+ return {
739
+ "total_requests": total,
740
+ "approved_requests": approved,
741
+ "denied_requests": total - approved,
742
+ "approval_rate": approved / total,
743
+ "risk_level_distribution": risk_levels,
744
+ "operation_type_distribution": operation_types,
745
+ "recent_activity": self.approval_history[-10:], # Last 10 entries
746
+ }
747
+
748
+ def cleanup_expired_requests(self):
749
+ """Clean up expired batch requests."""
750
+ expired_ids = []
751
+ for batch_id, batch_request in self.pending_batches.items():
752
+ if batch_request.is_expired():
753
+ batch_request.status = ApprovalStatus.EXPIRED
754
+ self.completed_batches[batch_id] = batch_request
755
+ expired_ids.append(batch_id)
756
+
757
+ for batch_id in expired_ids:
758
+ del self.pending_batches[batch_id]
759
+
760
+ return len(expired_ids)