iflow-mcp-m507_ai-soc-agent 1.0.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.
- iflow_mcp_m507_ai_soc_agent-1.0.0.dist-info/METADATA +410 -0
- iflow_mcp_m507_ai_soc_agent-1.0.0.dist-info/RECORD +85 -0
- iflow_mcp_m507_ai_soc_agent-1.0.0.dist-info/WHEEL +5 -0
- iflow_mcp_m507_ai_soc_agent-1.0.0.dist-info/entry_points.txt +2 -0
- iflow_mcp_m507_ai_soc_agent-1.0.0.dist-info/licenses/LICENSE +21 -0
- iflow_mcp_m507_ai_soc_agent-1.0.0.dist-info/top_level.txt +1 -0
- src/__init__.py +8 -0
- src/ai_controller/README.md +139 -0
- src/ai_controller/__init__.py +12 -0
- src/ai_controller/agent_executor.py +596 -0
- src/ai_controller/cli/__init__.py +2 -0
- src/ai_controller/cli/main.py +243 -0
- src/ai_controller/session_manager.py +409 -0
- src/ai_controller/web/__init__.py +2 -0
- src/ai_controller/web/server.py +1181 -0
- src/ai_controller/web/static/css/README.md +102 -0
- src/api/__init__.py +13 -0
- src/api/case_management.py +271 -0
- src/api/edr.py +187 -0
- src/api/kb.py +136 -0
- src/api/siem.py +308 -0
- src/core/__init__.py +10 -0
- src/core/config.py +242 -0
- src/core/config_storage.py +684 -0
- src/core/dto.py +50 -0
- src/core/errors.py +36 -0
- src/core/logging.py +128 -0
- src/integrations/__init__.py +8 -0
- src/integrations/case_management/__init__.py +5 -0
- src/integrations/case_management/iris/__init__.py +11 -0
- src/integrations/case_management/iris/iris_client.py +885 -0
- src/integrations/case_management/iris/iris_http.py +274 -0
- src/integrations/case_management/iris/iris_mapper.py +263 -0
- src/integrations/case_management/iris/iris_models.py +128 -0
- src/integrations/case_management/thehive/__init__.py +8 -0
- src/integrations/case_management/thehive/thehive_client.py +193 -0
- src/integrations/case_management/thehive/thehive_http.py +147 -0
- src/integrations/case_management/thehive/thehive_mapper.py +190 -0
- src/integrations/case_management/thehive/thehive_models.py +125 -0
- src/integrations/cti/__init__.py +6 -0
- src/integrations/cti/local_tip/__init__.py +10 -0
- src/integrations/cti/local_tip/local_tip_client.py +90 -0
- src/integrations/cti/local_tip/local_tip_http.py +110 -0
- src/integrations/cti/opencti/__init__.py +10 -0
- src/integrations/cti/opencti/opencti_client.py +101 -0
- src/integrations/cti/opencti/opencti_http.py +418 -0
- src/integrations/edr/__init__.py +6 -0
- src/integrations/edr/elastic_defend/__init__.py +6 -0
- src/integrations/edr/elastic_defend/elastic_defend_client.py +351 -0
- src/integrations/edr/elastic_defend/elastic_defend_http.py +162 -0
- src/integrations/eng/__init__.py +10 -0
- src/integrations/eng/clickup/__init__.py +8 -0
- src/integrations/eng/clickup/clickup_client.py +513 -0
- src/integrations/eng/clickup/clickup_http.py +156 -0
- src/integrations/eng/github/__init__.py +8 -0
- src/integrations/eng/github/github_client.py +169 -0
- src/integrations/eng/github/github_http.py +158 -0
- src/integrations/eng/trello/__init__.py +8 -0
- src/integrations/eng/trello/trello_client.py +207 -0
- src/integrations/eng/trello/trello_http.py +162 -0
- src/integrations/kb/__init__.py +12 -0
- src/integrations/kb/fs_kb_client.py +313 -0
- src/integrations/siem/__init__.py +6 -0
- src/integrations/siem/elastic/__init__.py +6 -0
- src/integrations/siem/elastic/elastic_client.py +3319 -0
- src/integrations/siem/elastic/elastic_http.py +165 -0
- src/mcp/README.md +183 -0
- src/mcp/TOOLS.md +2827 -0
- src/mcp/__init__.py +13 -0
- src/mcp/__main__.py +18 -0
- src/mcp/agent_profiles.py +408 -0
- src/mcp/flow_agent_profiles.py +424 -0
- src/mcp/mcp_server.py +4086 -0
- src/mcp/rules_engine.py +487 -0
- src/mcp/runbook_manager.py +264 -0
- src/orchestrator/__init__.py +11 -0
- src/orchestrator/incident_workflow.py +244 -0
- src/orchestrator/tools_case.py +1085 -0
- src/orchestrator/tools_cti.py +359 -0
- src/orchestrator/tools_edr.py +315 -0
- src/orchestrator/tools_eng.py +378 -0
- src/orchestrator/tools_kb.py +156 -0
- src/orchestrator/tools_siem.py +1709 -0
- src/web/__init__.py +8 -0
- src/web/config_server.py +511 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AI Controller for SamiGPT.
|
|
3
|
+
|
|
4
|
+
This module provides a web-based controller for managing and executing agent commands,
|
|
5
|
+
with support for multiple concurrent sessions and scheduled auto-runs.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .agent_executor import AgentExecutor
|
|
9
|
+
from .session_manager import SessionManager
|
|
10
|
+
|
|
11
|
+
__all__ = ["AgentExecutor", "SessionManager"]
|
|
12
|
+
|
|
@@ -0,0 +1,596 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Agent executor for parsing and executing agent commands.
|
|
3
|
+
|
|
4
|
+
Supports commands like:
|
|
5
|
+
- "run lookup_hash_ti on <hash>"
|
|
6
|
+
- "run get_security_alerts"
|
|
7
|
+
- etc.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import shutil
|
|
17
|
+
import subprocess
|
|
18
|
+
from dataclasses import dataclass, asdict
|
|
19
|
+
from datetime import datetime
|
|
20
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
21
|
+
from enum import Enum
|
|
22
|
+
|
|
23
|
+
from ..core.config import SamiConfig
|
|
24
|
+
from ..core.config_storage import load_config_from_file
|
|
25
|
+
from ..core.logging import get_logger
|
|
26
|
+
|
|
27
|
+
logger = get_logger("sami.ai_controller.agent_executor")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CommandType(Enum):
|
|
31
|
+
"""Types of commands that can be executed."""
|
|
32
|
+
RUN_TOOL = "run_tool"
|
|
33
|
+
RUN_RUNBOOK = "run_runbook"
|
|
34
|
+
RUN_AGENT = "run_agent"
|
|
35
|
+
UNKNOWN = "unknown"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class Command:
|
|
40
|
+
"""Represents a parsed command."""
|
|
41
|
+
raw: str
|
|
42
|
+
command_type: CommandType
|
|
43
|
+
tool_name: Optional[str] = None
|
|
44
|
+
arguments: Dict[str, Any] = None
|
|
45
|
+
agent_name: Optional[str] = None
|
|
46
|
+
runbook_name: Optional[str] = None
|
|
47
|
+
|
|
48
|
+
def __post_init__(self):
|
|
49
|
+
if self.arguments is None:
|
|
50
|
+
self.arguments = {}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class ExecutionResult:
|
|
55
|
+
"""Result of an agent execution."""
|
|
56
|
+
success: bool
|
|
57
|
+
output: Any
|
|
58
|
+
error: Optional[str] = None
|
|
59
|
+
timestamp: Optional[datetime] = None
|
|
60
|
+
|
|
61
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
62
|
+
"""Convert to dictionary."""
|
|
63
|
+
result = asdict(self)
|
|
64
|
+
if self.timestamp:
|
|
65
|
+
result["timestamp"] = self.timestamp.isoformat()
|
|
66
|
+
|
|
67
|
+
# Ensure output is JSON-serializable
|
|
68
|
+
if result.get("output") is not None:
|
|
69
|
+
try:
|
|
70
|
+
# Try to serialize to ensure it's JSON-compatible
|
|
71
|
+
json.dumps(result["output"])
|
|
72
|
+
except (TypeError, ValueError):
|
|
73
|
+
# If not serializable, convert to string representation
|
|
74
|
+
result["output"] = {
|
|
75
|
+
"raw": str(result["output"]),
|
|
76
|
+
"text": str(result["output"])
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return result
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class AgentExecutor:
|
|
83
|
+
"""Executes agent commands and manages tool execution."""
|
|
84
|
+
|
|
85
|
+
def __init__(self, config: Optional[SamiConfig] = None):
|
|
86
|
+
"""Initialize the agent executor."""
|
|
87
|
+
self.config = config or load_config_from_file()
|
|
88
|
+
# Track currently running external process (e.g., cursor-agent) so we can cancel it
|
|
89
|
+
self._current_process: Optional[subprocess.Popen] = None
|
|
90
|
+
self._tool_registry: Dict[str, Callable] = {}
|
|
91
|
+
self._initialize_tools()
|
|
92
|
+
|
|
93
|
+
def _initialize_tools(self):
|
|
94
|
+
"""Initialize the tool registry with available tools."""
|
|
95
|
+
# Import tools from orchestrator
|
|
96
|
+
from ..orchestrator import tools_cti, tools_case, tools_edr, tools_siem, tools_kb
|
|
97
|
+
|
|
98
|
+
# CTI tools
|
|
99
|
+
self._tool_registry["lookup_hash_ti"] = tools_cti.lookup_hash_ti
|
|
100
|
+
# Add more tools as needed
|
|
101
|
+
|
|
102
|
+
# For now, we'll dynamically import tools as needed
|
|
103
|
+
logger.info(f"Initialized agent executor with {len(self._tool_registry)} tools")
|
|
104
|
+
|
|
105
|
+
def parse_command(self, command_str: str) -> Command:
|
|
106
|
+
"""
|
|
107
|
+
Parse a command string into a Command object.
|
|
108
|
+
|
|
109
|
+
Supported formats:
|
|
110
|
+
- "run <tool_name> on <arg_value>"
|
|
111
|
+
- "run <tool_name> with <arg_key>=<arg_value>"
|
|
112
|
+
- "run <tool_name> <arg_value>"
|
|
113
|
+
- "run <agent_name> agent on <target>"
|
|
114
|
+
- "run <runbook_name> runbook on <target>"
|
|
115
|
+
|
|
116
|
+
Supports quoted strings for tool names and values.
|
|
117
|
+
"""
|
|
118
|
+
command_str = command_str.strip()
|
|
119
|
+
|
|
120
|
+
# Helper to strip quotes
|
|
121
|
+
def strip_quotes(s: str) -> str:
|
|
122
|
+
s = s.strip()
|
|
123
|
+
if (s.startswith('"') and s.endswith('"')) or (s.startswith("'") and s.endswith("'")):
|
|
124
|
+
return s[1:-1]
|
|
125
|
+
return s
|
|
126
|
+
|
|
127
|
+
# Pattern: "run <tool_name> on <value>" (supports quoted tool names and values)
|
|
128
|
+
# First try with optional quotes around tool name
|
|
129
|
+
match = re.match(r'^run\s+("?)([\w_]+)\1\s+on\s+(.+)$', command_str, re.IGNORECASE)
|
|
130
|
+
if match:
|
|
131
|
+
tool_or_agent = match.group(2) # The tool name (without quotes)
|
|
132
|
+
value = match.group(3).strip() # The value after "on"
|
|
133
|
+
value = strip_quotes(value)
|
|
134
|
+
else:
|
|
135
|
+
# Try without quotes around tool name
|
|
136
|
+
match = re.match(r'^run\s+(\w+)\s+on\s+(.+)$', command_str, re.IGNORECASE)
|
|
137
|
+
if match:
|
|
138
|
+
tool_or_agent = match.group(1)
|
|
139
|
+
value = match.group(2).strip()
|
|
140
|
+
value = strip_quotes(value)
|
|
141
|
+
|
|
142
|
+
if match:
|
|
143
|
+
|
|
144
|
+
# Check if it's an agent
|
|
145
|
+
if tool_or_agent.endswith("_agent") or tool_or_agent in ["soc1", "soc2", "soc3"]:
|
|
146
|
+
return Command(
|
|
147
|
+
raw=command_str,
|
|
148
|
+
command_type=CommandType.RUN_AGENT,
|
|
149
|
+
agent_name=tool_or_agent,
|
|
150
|
+
arguments={"target": value}
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
# Check if it's a runbook
|
|
154
|
+
if "_runbook" in tool_or_agent or tool_or_agent.endswith("_triage") or tool_or_agent.endswith("_investigation"):
|
|
155
|
+
return Command(
|
|
156
|
+
raw=command_str,
|
|
157
|
+
command_type=CommandType.RUN_RUNBOOK,
|
|
158
|
+
runbook_name=tool_or_agent,
|
|
159
|
+
arguments={"target": value}
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
# Default to tool
|
|
163
|
+
# Try to infer the argument name based on tool name
|
|
164
|
+
arg_name = self._infer_argument_name(tool_or_agent)
|
|
165
|
+
return Command(
|
|
166
|
+
raw=command_str,
|
|
167
|
+
command_type=CommandType.RUN_TOOL,
|
|
168
|
+
tool_name=tool_or_agent,
|
|
169
|
+
arguments={arg_name: value}
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
# Pattern: "run <tool_name> with <key>=<value>" (supports quoted tool names)
|
|
173
|
+
match = re.match(r'^run\s+("?)([\w_]+)\1\s+with\s+(.+)$', command_str, re.IGNORECASE)
|
|
174
|
+
if match:
|
|
175
|
+
tool_name = match.group(2) # Tool name (without quotes)
|
|
176
|
+
params_str = match.group(3) # Parameters after "with"
|
|
177
|
+
else:
|
|
178
|
+
match = re.match(r'^run\s+(\w+)\s+with\s+(.+)$', command_str, re.IGNORECASE)
|
|
179
|
+
if match:
|
|
180
|
+
tool_name = match.group(1)
|
|
181
|
+
params_str = match.group(2)
|
|
182
|
+
|
|
183
|
+
if match:
|
|
184
|
+
arguments = self._parse_arguments(params_str)
|
|
185
|
+
return Command(
|
|
186
|
+
raw=command_str,
|
|
187
|
+
command_type=CommandType.RUN_TOOL,
|
|
188
|
+
tool_name=tool_name,
|
|
189
|
+
arguments=arguments
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
# Pattern: "run <tool_name> <value>" (simple single argument, supports quoted)
|
|
193
|
+
match = re.match(r'^run\s+("?)([\w_]+)\1\s+(.+)$', command_str, re.IGNORECASE)
|
|
194
|
+
if match:
|
|
195
|
+
tool_name = match.group(2) # Tool name (without quotes)
|
|
196
|
+
value = match.group(3).strip() # Value
|
|
197
|
+
value = strip_quotes(value)
|
|
198
|
+
else:
|
|
199
|
+
match = re.match(r'^run\s+(\w+)\s+(.+)$', command_str, re.IGNORECASE)
|
|
200
|
+
if match:
|
|
201
|
+
tool_name = match.group(1)
|
|
202
|
+
value = match.group(2).strip()
|
|
203
|
+
value = strip_quotes(value)
|
|
204
|
+
|
|
205
|
+
if match:
|
|
206
|
+
arg_name = self._infer_argument_name(tool_name)
|
|
207
|
+
return Command(
|
|
208
|
+
raw=command_str,
|
|
209
|
+
command_type=CommandType.RUN_TOOL,
|
|
210
|
+
tool_name=tool_name,
|
|
211
|
+
arguments={arg_name: value}
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
# Unknown command - treat as freeform prompt for external agent
|
|
215
|
+
# (e.g., Cursor IDE cursor-agent). We don't log a warning here to
|
|
216
|
+
# avoid confusing users when they enter natural language prompts.
|
|
217
|
+
logger.debug(f"Treating input as freeform prompt: {command_str}")
|
|
218
|
+
return Command(
|
|
219
|
+
raw=command_str,
|
|
220
|
+
command_type=CommandType.UNKNOWN
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
def _infer_argument_name(self, tool_name: str) -> str:
|
|
224
|
+
"""Infer the argument name based on tool name."""
|
|
225
|
+
# Common patterns
|
|
226
|
+
if "hash" in tool_name.lower():
|
|
227
|
+
return "hash_value"
|
|
228
|
+
elif "ip" in tool_name.lower():
|
|
229
|
+
return "ip"
|
|
230
|
+
elif "domain" in tool_name.lower():
|
|
231
|
+
return "domain"
|
|
232
|
+
elif "alert" in tool_name.lower():
|
|
233
|
+
return "alert_id"
|
|
234
|
+
elif "case" in tool_name.lower():
|
|
235
|
+
return "case_id"
|
|
236
|
+
elif "user" in tool_name.lower():
|
|
237
|
+
return "username"
|
|
238
|
+
else:
|
|
239
|
+
return "value"
|
|
240
|
+
|
|
241
|
+
def _parse_arguments(self, params_str: str) -> Dict[str, Any]:
|
|
242
|
+
"""Parse argument string like 'key1=value1 key2=value2'."""
|
|
243
|
+
arguments = {}
|
|
244
|
+
# Simple parsing - can be enhanced
|
|
245
|
+
for param in params_str.split():
|
|
246
|
+
if "=" in param:
|
|
247
|
+
key, value = param.split("=", 1)
|
|
248
|
+
arguments[key.strip()] = value.strip()
|
|
249
|
+
return arguments
|
|
250
|
+
|
|
251
|
+
async def execute_command(self, command: Command) -> ExecutionResult:
|
|
252
|
+
"""
|
|
253
|
+
Execute a parsed command and return the result.
|
|
254
|
+
|
|
255
|
+
This method handles tool execution and can be extended to support
|
|
256
|
+
agents and runbooks.
|
|
257
|
+
"""
|
|
258
|
+
try:
|
|
259
|
+
if command.command_type == CommandType.RUN_TOOL:
|
|
260
|
+
return await self._execute_tool(command)
|
|
261
|
+
elif command.command_type == CommandType.RUN_AGENT:
|
|
262
|
+
return await self._execute_agent(command)
|
|
263
|
+
elif command.command_type == CommandType.RUN_RUNBOOK:
|
|
264
|
+
return await self._execute_runbook(command)
|
|
265
|
+
elif command.command_type == CommandType.UNKNOWN:
|
|
266
|
+
# Fallback: treat as freeform prompt and forward to external agent
|
|
267
|
+
return await self._execute_freeform_prompt(command.raw)
|
|
268
|
+
else:
|
|
269
|
+
return ExecutionResult(
|
|
270
|
+
success=False,
|
|
271
|
+
output=None,
|
|
272
|
+
error=f"Unknown command type: {command.command_type}",
|
|
273
|
+
timestamp=datetime.now()
|
|
274
|
+
)
|
|
275
|
+
except Exception as e:
|
|
276
|
+
logger.exception(f"Error executing command: {command.raw}")
|
|
277
|
+
return ExecutionResult(
|
|
278
|
+
success=False,
|
|
279
|
+
output=None,
|
|
280
|
+
error=str(e),
|
|
281
|
+
timestamp=datetime.now()
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
async def _execute_tool(self, command: Command) -> ExecutionResult:
|
|
285
|
+
"""Execute a tool command."""
|
|
286
|
+
if not command.tool_name:
|
|
287
|
+
return ExecutionResult(
|
|
288
|
+
success=False,
|
|
289
|
+
output=None,
|
|
290
|
+
error="No tool name specified",
|
|
291
|
+
timestamp=datetime.now()
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
# Check if tool is registered
|
|
295
|
+
if command.tool_name not in self._tool_registry:
|
|
296
|
+
# Try to dynamically import the tool
|
|
297
|
+
tool_func = await self._load_tool(command.tool_name)
|
|
298
|
+
if not tool_func:
|
|
299
|
+
return ExecutionResult(
|
|
300
|
+
success=False,
|
|
301
|
+
output=None,
|
|
302
|
+
error=f"Tool '{command.tool_name}' not found",
|
|
303
|
+
timestamp=datetime.now()
|
|
304
|
+
)
|
|
305
|
+
self._tool_registry[command.tool_name] = tool_func
|
|
306
|
+
|
|
307
|
+
tool_func = self._tool_registry[command.tool_name]
|
|
308
|
+
|
|
309
|
+
# Prepare clients based on tool requirements
|
|
310
|
+
# Prepare clients before the lambda to avoid import issues
|
|
311
|
+
clients = self._prepare_clients(command.tool_name)
|
|
312
|
+
|
|
313
|
+
if not clients:
|
|
314
|
+
return ExecutionResult(
|
|
315
|
+
success=False,
|
|
316
|
+
output=None,
|
|
317
|
+
error=f"No clients available for tool '{command.tool_name}'. Check configuration.",
|
|
318
|
+
timestamp=datetime.now()
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
# Execute tool in executor to avoid blocking
|
|
322
|
+
loop = asyncio.get_event_loop()
|
|
323
|
+
try:
|
|
324
|
+
# Capture clients in closure before lambda
|
|
325
|
+
tool_args = command.arguments.copy()
|
|
326
|
+
if isinstance(clients, list):
|
|
327
|
+
tool_args["clients"] = clients
|
|
328
|
+
else:
|
|
329
|
+
tool_args["client"] = clients
|
|
330
|
+
|
|
331
|
+
result = await loop.run_in_executor(
|
|
332
|
+
None,
|
|
333
|
+
lambda: tool_func(**tool_args)
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
return ExecutionResult(
|
|
337
|
+
success=True,
|
|
338
|
+
output=result,
|
|
339
|
+
timestamp=datetime.now()
|
|
340
|
+
)
|
|
341
|
+
except Exception as e:
|
|
342
|
+
logger.exception(f"Error executing tool {command.tool_name}")
|
|
343
|
+
return ExecutionResult(
|
|
344
|
+
success=False,
|
|
345
|
+
output=None,
|
|
346
|
+
error=str(e),
|
|
347
|
+
timestamp=datetime.now()
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
async def _execute_agent(self, command: Command) -> ExecutionResult:
|
|
351
|
+
"""Execute an agent command (for future implementation)."""
|
|
352
|
+
# TODO: Implement agent execution
|
|
353
|
+
return ExecutionResult(
|
|
354
|
+
success=False,
|
|
355
|
+
output=None,
|
|
356
|
+
error="Agent execution not yet implemented",
|
|
357
|
+
timestamp=datetime.now()
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
async def _execute_runbook(self, command: Command) -> ExecutionResult:
|
|
361
|
+
"""Execute a runbook command (for future implementation)."""
|
|
362
|
+
# TODO: Implement runbook execution
|
|
363
|
+
return ExecutionResult(
|
|
364
|
+
success=False,
|
|
365
|
+
output=None,
|
|
366
|
+
error="Runbook execution not yet implemented",
|
|
367
|
+
timestamp=datetime.now()
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
async def _execute_freeform_prompt(self, prompt: str) -> ExecutionResult:
|
|
371
|
+
"""
|
|
372
|
+
Execute a freeform prompt by forwarding it to an external agent
|
|
373
|
+
(Cursor IDE's cursor-agent binary).
|
|
374
|
+
|
|
375
|
+
This allows the AI Controller UI to behave like a normal terminal
|
|
376
|
+
where arbitrary prompts are handled by the agent, without requiring
|
|
377
|
+
strict 'run <tool>' syntax.
|
|
378
|
+
"""
|
|
379
|
+
|
|
380
|
+
loop = asyncio.get_event_loop()
|
|
381
|
+
|
|
382
|
+
def _run_cursor_agent(prompt_text: str) -> Dict[str, Any]:
|
|
383
|
+
"""
|
|
384
|
+
Run the external cursor-agent process and capture its output.
|
|
385
|
+
|
|
386
|
+
Uses:
|
|
387
|
+
cursor-agent --print --output-format text "<prompt>"
|
|
388
|
+
"""
|
|
389
|
+
|
|
390
|
+
# Try to locate cursor-agent binary
|
|
391
|
+
possible_paths = [
|
|
392
|
+
"/usr/local/bin/cursor-agent",
|
|
393
|
+
"/usr/bin/cursor-agent",
|
|
394
|
+
os.path.expanduser("~/.local/bin/cursor-agent"),
|
|
395
|
+
"/opt/homebrew/bin/cursor-agent",
|
|
396
|
+
]
|
|
397
|
+
|
|
398
|
+
cursor_agent_bin: Optional[str] = None
|
|
399
|
+
|
|
400
|
+
for path in possible_paths:
|
|
401
|
+
if os.path.exists(path) and os.access(path, os.X_OK):
|
|
402
|
+
cursor_agent_bin = path
|
|
403
|
+
break
|
|
404
|
+
|
|
405
|
+
if cursor_agent_bin is None:
|
|
406
|
+
# Fall back to PATH lookup
|
|
407
|
+
cursor_agent_bin = shutil.which("cursor-agent")
|
|
408
|
+
|
|
409
|
+
if cursor_agent_bin is None:
|
|
410
|
+
raise RuntimeError(
|
|
411
|
+
"Cursor IDE 'cursor-agent' binary not found in common locations or PATH. "
|
|
412
|
+
"Install Cursor and ensure 'cursor-agent' is available."
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
# Build command
|
|
416
|
+
# --approve-mcps: auto-approve MCP server/tool usage so Cursor
|
|
417
|
+
# doesn't prompt interactively for each tool call.
|
|
418
|
+
cmd = [
|
|
419
|
+
cursor_agent_bin,
|
|
420
|
+
"--force",
|
|
421
|
+
"--approve-mcps",
|
|
422
|
+
"--print",
|
|
423
|
+
"--output-format",
|
|
424
|
+
"text",
|
|
425
|
+
prompt_text,
|
|
426
|
+
]
|
|
427
|
+
|
|
428
|
+
logger.debug(f"Executing external cursor-agent: {' '.join(cmd)}")
|
|
429
|
+
|
|
430
|
+
# Use Popen so we can terminate the process on cancellation
|
|
431
|
+
proc = subprocess.Popen(
|
|
432
|
+
cmd,
|
|
433
|
+
stdout=subprocess.PIPE,
|
|
434
|
+
stderr=subprocess.PIPE,
|
|
435
|
+
text=True,
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
# Register process so it can be cancelled from outside
|
|
439
|
+
self._current_process = proc
|
|
440
|
+
try:
|
|
441
|
+
stdout, stderr = proc.communicate()
|
|
442
|
+
finally:
|
|
443
|
+
# Clear reference once process is finished
|
|
444
|
+
self._current_process = None
|
|
445
|
+
|
|
446
|
+
return {
|
|
447
|
+
"returncode": proc.returncode,
|
|
448
|
+
"stdout": (stdout or "").strip(),
|
|
449
|
+
"stderr": (stderr or "").strip(),
|
|
450
|
+
"command": cmd,
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
try:
|
|
454
|
+
result = await loop.run_in_executor(None, _run_cursor_agent, prompt)
|
|
455
|
+
|
|
456
|
+
success = result.get("returncode", 1) == 0
|
|
457
|
+
output: Dict[str, Any] = {
|
|
458
|
+
"stdout": result.get("stdout", ""),
|
|
459
|
+
"stderr": result.get("stderr", ""),
|
|
460
|
+
"command": " ".join(result.get("command", [])),
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
# Prefer stdout as primary output for UI
|
|
464
|
+
primary_output = result.get("stdout", "") or result.get("stderr", "")
|
|
465
|
+
|
|
466
|
+
return ExecutionResult(
|
|
467
|
+
success=success,
|
|
468
|
+
output={
|
|
469
|
+
"raw": output,
|
|
470
|
+
"text": primary_output,
|
|
471
|
+
},
|
|
472
|
+
error=None if success else result.get("stderr", "cursor-agent failed"),
|
|
473
|
+
timestamp=datetime.now(),
|
|
474
|
+
)
|
|
475
|
+
except Exception as e:
|
|
476
|
+
logger.exception("Error forwarding prompt to external cursor-agent")
|
|
477
|
+
return ExecutionResult(
|
|
478
|
+
success=False,
|
|
479
|
+
output=None,
|
|
480
|
+
error=str(e),
|
|
481
|
+
timestamp=datetime.now(),
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
def cancel_current_execution(self):
|
|
485
|
+
"""
|
|
486
|
+
Best-effort cancellation of any currently running external process.
|
|
487
|
+
|
|
488
|
+
This is primarily used to stop a long-running cursor-agent process when
|
|
489
|
+
the user clicks Stop or closes the session in the web UI.
|
|
490
|
+
"""
|
|
491
|
+
proc = self._current_process
|
|
492
|
+
if not proc:
|
|
493
|
+
return
|
|
494
|
+
|
|
495
|
+
if proc.poll() is not None:
|
|
496
|
+
# Already finished
|
|
497
|
+
self._current_process = None
|
|
498
|
+
return
|
|
499
|
+
|
|
500
|
+
try:
|
|
501
|
+
logger.info("Attempting to terminate external process (pid=%s)", proc.pid)
|
|
502
|
+
proc.terminate()
|
|
503
|
+
try:
|
|
504
|
+
proc.wait(timeout=5)
|
|
505
|
+
except subprocess.TimeoutExpired:
|
|
506
|
+
logger.warning("Process did not exit after terminate, killing (pid=%s)", proc.pid)
|
|
507
|
+
proc.kill()
|
|
508
|
+
except Exception as e:
|
|
509
|
+
logger.warning("Failed to cancel external process: %s", e)
|
|
510
|
+
finally:
|
|
511
|
+
self._current_process = None
|
|
512
|
+
|
|
513
|
+
async def _load_tool(self, tool_name: str) -> Optional[Callable]:
|
|
514
|
+
"""Dynamically load a tool function."""
|
|
515
|
+
try:
|
|
516
|
+
# Map tool names to their modules and functions
|
|
517
|
+
tool_mapping = {
|
|
518
|
+
"lookup_hash_ti": ("..orchestrator.tools_cti", "lookup_hash_ti"),
|
|
519
|
+
"get_security_alerts": ("..orchestrator.tools_siem", "get_security_alerts"),
|
|
520
|
+
"get_security_alert_by_id": ("..orchestrator.tools_siem", "get_security_alert_by_id"),
|
|
521
|
+
"lookup_ip_ti": ("..orchestrator.tools_cti", "lookup_ip_ti"),
|
|
522
|
+
"get_ip_address_report": ("..orchestrator.tools_siem", "get_ip_address_report"),
|
|
523
|
+
# Add more mappings as needed
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
if tool_name in tool_mapping:
|
|
527
|
+
module_path, func_name = tool_mapping[tool_name]
|
|
528
|
+
module = __import__(module_path, fromlist=[func_name])
|
|
529
|
+
return getattr(module, func_name)
|
|
530
|
+
|
|
531
|
+
return None
|
|
532
|
+
except Exception as e:
|
|
533
|
+
logger.error(f"Failed to load tool {tool_name}: {e}")
|
|
534
|
+
return None
|
|
535
|
+
|
|
536
|
+
def _prepare_clients(self, tool_name: str):
|
|
537
|
+
"""Prepare client objects needed for tool execution."""
|
|
538
|
+
try:
|
|
539
|
+
# Determine which clients are needed based on tool name
|
|
540
|
+
clients = []
|
|
541
|
+
|
|
542
|
+
if "cti" in tool_name.lower() or "ti" in tool_name.lower():
|
|
543
|
+
# Need CTI clients - load from config dict to support cti_opencti
|
|
544
|
+
from src.core.config_storage import get_config_dict
|
|
545
|
+
from src.core.config import CTIConfig, SamiConfig
|
|
546
|
+
|
|
547
|
+
config_dict = get_config_dict()
|
|
548
|
+
|
|
549
|
+
if self.config.cti:
|
|
550
|
+
from src.integrations.cti.local_tip.local_tip_client import LocalTipCTIClient
|
|
551
|
+
clients.append(LocalTipCTIClient.from_config(self.config))
|
|
552
|
+
|
|
553
|
+
# Check for cti_opencti in config dict (not in SamiConfig dataclass)
|
|
554
|
+
if config_dict.get("cti_opencti"):
|
|
555
|
+
from src.integrations.cti.opencti.opencti_client import OpenCTIClient
|
|
556
|
+
cti_opencti_config = config_dict["cti_opencti"]
|
|
557
|
+
opencti_cfg = CTIConfig(
|
|
558
|
+
cti_type=cti_opencti_config.get("cti_type", "opencti"),
|
|
559
|
+
base_url=cti_opencti_config.get("base_url"),
|
|
560
|
+
api_key=cti_opencti_config.get("api_key"),
|
|
561
|
+
timeout_seconds=cti_opencti_config.get("timeout_seconds", 30),
|
|
562
|
+
verify_ssl=cti_opencti_config.get("verify_ssl", False),
|
|
563
|
+
)
|
|
564
|
+
temp_config = SamiConfig(cti=opencti_cfg)
|
|
565
|
+
clients.append(OpenCTIClient.from_config(temp_config))
|
|
566
|
+
|
|
567
|
+
if "siem" in tool_name.lower() or "alert" in tool_name.lower():
|
|
568
|
+
# Need SIEM client
|
|
569
|
+
if self.config.elastic:
|
|
570
|
+
from src.integrations.siem.elastic.elastic_client import ElasticSIEMClient
|
|
571
|
+
clients.append(ElasticSIEMClient.from_config(self.config))
|
|
572
|
+
|
|
573
|
+
if "edr" in tool_name.lower():
|
|
574
|
+
# Need EDR client
|
|
575
|
+
if self.config.edr:
|
|
576
|
+
from src.integrations.edr.elastic_defend.elastic_defend_client import ElasticDefendEDRClient
|
|
577
|
+
clients.append(ElasticDefendEDRClient.from_config(self.config))
|
|
578
|
+
|
|
579
|
+
if "case" in tool_name.lower():
|
|
580
|
+
# Need case management client
|
|
581
|
+
if self.config.iris:
|
|
582
|
+
from src.integrations.case_management.iris.iris_client import IRISCaseManagementClient
|
|
583
|
+
clients.append(IRISCaseManagementClient.from_config(self.config))
|
|
584
|
+
elif self.config.thehive:
|
|
585
|
+
from src.integrations.case_management.thehive.thehive_client import TheHiveCaseManagementClient
|
|
586
|
+
clients.append(TheHiveCaseManagementClient.from_config(self.config))
|
|
587
|
+
|
|
588
|
+
if not clients:
|
|
589
|
+
logger.warning(f"No clients found for tool {tool_name}. Check configuration.")
|
|
590
|
+
return None
|
|
591
|
+
|
|
592
|
+
return clients[0] if len(clients) == 1 else clients
|
|
593
|
+
except Exception as e:
|
|
594
|
+
logger.exception(f"Failed to prepare clients for {tool_name}: {e}")
|
|
595
|
+
return None
|
|
596
|
+
|