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,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("<", "<").replace(">", ">"),
|
|
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)
|