kmcp 0.1.5.dev0__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.
- kali_mcp/__init__.py +3 -0
- kali_mcp/__main__.py +28 -0
- kali_mcp/_version.py +24 -0
- kali_mcp/cli.py +169 -0
- kali_mcp/config.py +95 -0
- kali_mcp/db/__init__.py +1 -0
- kali_mcp/db/manager.py +248 -0
- kali_mcp/execution.py +439 -0
- kali_mcp/logging_utils.py +74 -0
- kali_mcp/models.py +51 -0
- kali_mcp/monitor/__init__.py +105 -0
- kali_mcp/monitor/sessions.py +70 -0
- kali_mcp/monitor/sse_client.py +87 -0
- kali_mcp/monitor/tui.py +364 -0
- kali_mcp/notifications.py +130 -0
- kali_mcp/prompts.py +184 -0
- kali_mcp/registry.py +33 -0
- kali_mcp/resources.py +263 -0
- kali_mcp/responses.py +39 -0
- kali_mcp/security.py +15 -0
- kali_mcp/server.py +535 -0
- kali_mcp/tools/__init__.py +125 -0
- kali_mcp/tools/amass.py +83 -0
- kali_mcp/tools/arjun.py +86 -0
- kali_mcp/tools/base.py +100 -0
- kali_mcp/tools/bettercap.py +99 -0
- kali_mcp/tools/bloodhound.py +68 -0
- kali_mcp/tools/commix.py +73 -0
- kali_mcp/tools/crackmapexec.py +100 -0
- kali_mcp/tools/dirb.py +75 -0
- kali_mcp/tools/dursgo.py +156 -0
- kali_mcp/tools/enum4linux.py +59 -0
- kali_mcp/tools/farsight.py +127 -0
- kali_mcp/tools/ffuf.py +85 -0
- kali_mcp/tools/file_download.py +119 -0
- kali_mcp/tools/file_read.py +144 -0
- kali_mcp/tools/file_write.py +141 -0
- kali_mcp/tools/flowlyt.py +88 -0
- kali_mcp/tools/generic_command.py +95 -0
- kali_mcp/tools/gobuster.py +84 -0
- kali_mcp/tools/hashcat.py +101 -0
- kali_mcp/tools/httpx.py +85 -0
- kali_mcp/tools/hydra.py +99 -0
- kali_mcp/tools/john.py +100 -0
- kali_mcp/tools/katana.py +81 -0
- kali_mcp/tools/masscan.py +85 -0
- kali_mcp/tools/medusa.py +102 -0
- kali_mcp/tools/msfconsole.py +59 -0
- kali_mcp/tools/naabu.py +100 -0
- kali_mcp/tools/netexec.py +86 -0
- kali_mcp/tools/nmap.py +94 -0
- kali_mcp/tools/normalization.py +18 -0
- kali_mcp/tools/nuclei.py +87 -0
- kali_mcp/tools/parallel_execute.py +246 -0
- kali_mcp/tools/persist.py +436 -0
- kali_mcp/tools/playbook_ad.py +99 -0
- kali_mcp/tools/playbook_engine.py +142 -0
- kali_mcp/tools/playbook_engine_tool.py +72 -0
- kali_mcp/tools/playbook_recon.py +115 -0
- kali_mcp/tools/playbook_tools.py +125 -0
- kali_mcp/tools/playbook_webapp.py +105 -0
- kali_mcp/tools/python_command.py +100 -0
- kali_mcp/tools/responder.py +85 -0
- kali_mcp/tools/searchsploit.py +123 -0
- kali_mcp/tools/spiderfoot.py +75 -0
- kali_mcp/tools/sqlmap.py +73 -0
- kali_mcp/tools/subfinder.py +69 -0
- kali_mcp/tools/theharvester.py +80 -0
- kali_mcp/tools/vault_tool.py +489 -0
- kali_mcp/tools/wfuzz.py +87 -0
- kali_mcp/tools/whatweb.py +82 -0
- kali_mcp/tools/wpscan.py +76 -0
- kali_mcp/tools/zap.py +79 -0
- kali_mcp/tools/zighound.py +139 -0
- kali_mcp/tools/zizmor.py +98 -0
- kali_mcp/utils/__init__.py +0 -0
- kali_mcp/utils/process.py +36 -0
- kali_mcp/utils/vault.py +471 -0
- kali_mcp/validation.py +82 -0
- kmcp-0.1.5.dev0.dist-info/METADATA +251 -0
- kmcp-0.1.5.dev0.dist-info/RECORD +84 -0
- kmcp-0.1.5.dev0.dist-info/WHEEL +5 -0
- kmcp-0.1.5.dev0.dist-info/entry_points.txt +4 -0
- kmcp-0.1.5.dev0.dist-info/top_level.txt +1 -0
kali_mcp/execution.py
ADDED
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
"""Single execution engine — hardened for resilience.
|
|
2
|
+
|
|
3
|
+
3-layer defense:
|
|
4
|
+
1. asyncio.wait_for with enforced timeout (tool can't hang server)
|
|
5
|
+
2. Watchdog thread kills stuck executions
|
|
6
|
+
3. Semaphore prevents resource exhaustion from concurrent calls
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import contextvars
|
|
13
|
+
import gc
|
|
14
|
+
import inspect
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
import uuid
|
|
20
|
+
|
|
21
|
+
from kali_mcp.config import config
|
|
22
|
+
from kali_mcp.logging_utils import log_execution
|
|
23
|
+
from kali_mcp.models import ExecutionResult, utc_now_iso
|
|
24
|
+
from kali_mcp.security import sanitize_command_parts
|
|
25
|
+
from kali_mcp.utils.process import kill_process_tree
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger("kali_mcp.execution")
|
|
28
|
+
|
|
29
|
+
MAX_OUTPUT = 100_000 # 100KB per stream
|
|
30
|
+
|
|
31
|
+
# Per-request progress callback, set by the MCP handler so tool executions
|
|
32
|
+
# report progress without each tool needing to thread the callback through.
|
|
33
|
+
_progress_ctx: contextvars.ContextVar = contextvars.ContextVar("kali_mcp_progress", default=None)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def set_progress_cb(cb) -> contextvars.Token | None:
|
|
37
|
+
"""Set the current task's progress callback; returns a token to reset."""
|
|
38
|
+
if cb is None:
|
|
39
|
+
return None
|
|
40
|
+
return _progress_ctx.set(cb)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def reset_progress_cb(token) -> None:
|
|
44
|
+
if token is not None:
|
|
45
|
+
_progress_ctx.reset(token)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def _call_progress(progress_cb, completed: int, total: int) -> None:
|
|
49
|
+
"""Invoke a progress callback, supporting sync and async callables."""
|
|
50
|
+
result = progress_cb(completed, total)
|
|
51
|
+
if inspect.isawaitable(result):
|
|
52
|
+
await result
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ExecutionEngine:
|
|
56
|
+
"""Runs subprocess commands with timeout, captures output, returns structured results.
|
|
57
|
+
|
|
58
|
+
Hardened against:
|
|
59
|
+
- Tool subprocess hangs (asyncio.wait_for timeout)
|
|
60
|
+
- Tool subprocess zombies (kill_process_tree)
|
|
61
|
+
- Event loop starvation (semaphore with max_concurrent)
|
|
62
|
+
- Stuck executions (watchdog thread)
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(self) -> None:
|
|
66
|
+
self._semaphore = asyncio.Semaphore(config.max_concurrent)
|
|
67
|
+
self._active: dict[str, str] = {} # request_id → tool
|
|
68
|
+
self._request_times: dict[str, float] = {} # request_id → start monotonic
|
|
69
|
+
self._procs: dict[str, asyncio.subprocess.Process] = {} # request_id → process
|
|
70
|
+
self._watchdog_running = False
|
|
71
|
+
self._request_count = 0
|
|
72
|
+
self._error_count = 0
|
|
73
|
+
self._lock = threading.Lock()
|
|
74
|
+
|
|
75
|
+
def _start_watchdog(self) -> None:
|
|
76
|
+
"""Start background watchdog that kills stuck executions."""
|
|
77
|
+
if self._watchdog_running:
|
|
78
|
+
return
|
|
79
|
+
self._watchdog_running = True
|
|
80
|
+
|
|
81
|
+
def _watchdog():
|
|
82
|
+
while self._watchdog_running:
|
|
83
|
+
time.sleep(5)
|
|
84
|
+
now = time.monotonic()
|
|
85
|
+
stuck = [
|
|
86
|
+
(rid, self._active.get(rid, "unknown"), t)
|
|
87
|
+
for rid, t in list(self._request_times.items())
|
|
88
|
+
if now - t > config.max_timeout + 30
|
|
89
|
+
]
|
|
90
|
+
for rid, tool, t in stuck:
|
|
91
|
+
logger.warning(
|
|
92
|
+
"watchdog: request=%s tool=%s stuck for %.0fs — force cleaning",
|
|
93
|
+
rid,
|
|
94
|
+
tool,
|
|
95
|
+
now - t,
|
|
96
|
+
)
|
|
97
|
+
self._active.pop(rid, None)
|
|
98
|
+
self._request_times.pop(rid, None)
|
|
99
|
+
|
|
100
|
+
t = threading.Thread(target=_watchdog, daemon=True, name="exec-watchdog")
|
|
101
|
+
t.start()
|
|
102
|
+
|
|
103
|
+
def _cleanup_request(self, req_id: str) -> None:
|
|
104
|
+
self._active.pop(req_id, None)
|
|
105
|
+
self._request_times.pop(req_id, None)
|
|
106
|
+
self._procs.pop(req_id, None)
|
|
107
|
+
|
|
108
|
+
async def execute(
|
|
109
|
+
self,
|
|
110
|
+
command: list[str] | str,
|
|
111
|
+
tool: str = "generic_command",
|
|
112
|
+
timeout: int | None = None,
|
|
113
|
+
cwd: str | None = None,
|
|
114
|
+
env: dict[str, str] | None = None,
|
|
115
|
+
request_id: str | None = None,
|
|
116
|
+
progress_cb: object | None = None,
|
|
117
|
+
) -> ExecutionResult:
|
|
118
|
+
"""Execute a command and return structured result. Never raises.
|
|
119
|
+
If command is a str, runs via create_subprocess_shell (for shell commands).
|
|
120
|
+
If command is a list, runs via create_subprocess_exec (for direct binary exec).
|
|
121
|
+
progress_cb, if given, is called as progress_cb(completed, total) over time.
|
|
122
|
+
"""
|
|
123
|
+
self._start_watchdog()
|
|
124
|
+
req_id = request_id or uuid.uuid4().hex[:12]
|
|
125
|
+
try:
|
|
126
|
+
effective_timeout = min(int(timeout or config.default_timeout), config.max_timeout)
|
|
127
|
+
except (ValueError, TypeError):
|
|
128
|
+
effective_timeout = config.default_timeout
|
|
129
|
+
|
|
130
|
+
use_shell = isinstance(command, str)
|
|
131
|
+
command_str = command if use_shell else " ".join(command)
|
|
132
|
+
if not use_shell:
|
|
133
|
+
command = sanitize_command_parts(command)
|
|
134
|
+
|
|
135
|
+
if cwd is not None and not cwd.strip():
|
|
136
|
+
cwd = None
|
|
137
|
+
|
|
138
|
+
logger.info(
|
|
139
|
+
"request=%s executing tool=%s command=%s timeout=%d shell=%s",
|
|
140
|
+
req_id,
|
|
141
|
+
tool,
|
|
142
|
+
command_str,
|
|
143
|
+
effective_timeout,
|
|
144
|
+
use_shell,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
start_time = utc_now_iso()
|
|
148
|
+
start_monotonic = time.monotonic()
|
|
149
|
+
if progress_cb is None:
|
|
150
|
+
progress_cb = _progress_ctx.get()
|
|
151
|
+
if progress_cb is not None:
|
|
152
|
+
try:
|
|
153
|
+
await _call_progress(progress_cb, 0, 100)
|
|
154
|
+
except Exception: # noqa: BLE001
|
|
155
|
+
progress_cb = None
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
async with self._semaphore:
|
|
159
|
+
self._active[req_id] = tool
|
|
160
|
+
self._request_times[req_id] = start_monotonic
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
exec_env = env if env is not None else os.environ.copy()
|
|
164
|
+
system_path = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/local/games:/usr/games"
|
|
165
|
+
existing = exec_env.get("PATH", "")
|
|
166
|
+
exec_env["PATH"] = f"{existing}:{system_path}" if existing else system_path
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
if use_shell:
|
|
170
|
+
proc = await asyncio.create_subprocess_exec(
|
|
171
|
+
"/bin/bash",
|
|
172
|
+
"-c",
|
|
173
|
+
command,
|
|
174
|
+
stdout=asyncio.subprocess.PIPE,
|
|
175
|
+
stderr=asyncio.subprocess.PIPE,
|
|
176
|
+
cwd=cwd,
|
|
177
|
+
env=exec_env,
|
|
178
|
+
start_new_session=True,
|
|
179
|
+
)
|
|
180
|
+
else:
|
|
181
|
+
proc = await asyncio.create_subprocess_exec(
|
|
182
|
+
*command,
|
|
183
|
+
stdout=asyncio.subprocess.PIPE,
|
|
184
|
+
stderr=asyncio.subprocess.PIPE,
|
|
185
|
+
cwd=cwd,
|
|
186
|
+
env=exec_env,
|
|
187
|
+
start_new_session=True,
|
|
188
|
+
)
|
|
189
|
+
self._procs[req_id] = proc
|
|
190
|
+
except FileNotFoundError:
|
|
191
|
+
elapsed = time.monotonic() - start_monotonic
|
|
192
|
+
if use_shell:
|
|
193
|
+
binary = "/bin/bash"
|
|
194
|
+
hint = "install it with 'apt install bash'"
|
|
195
|
+
else:
|
|
196
|
+
binary = command[0] if command else "unknown"
|
|
197
|
+
hint = f"install it with 'apt install {binary}' or check PATH"
|
|
198
|
+
logger.warning(
|
|
199
|
+
"request=%s tool=%s binary not found: %s",
|
|
200
|
+
req_id,
|
|
201
|
+
tool,
|
|
202
|
+
binary,
|
|
203
|
+
)
|
|
204
|
+
result = ExecutionResult(
|
|
205
|
+
tool=tool,
|
|
206
|
+
command=command_str,
|
|
207
|
+
stdout="",
|
|
208
|
+
stderr=f"Binary not found: {binary} — {hint}",
|
|
209
|
+
exit_code=-1,
|
|
210
|
+
success=False,
|
|
211
|
+
timed_out=False,
|
|
212
|
+
duration=round(elapsed, 3),
|
|
213
|
+
start_time=start_time,
|
|
214
|
+
end_time=utc_now_iso(),
|
|
215
|
+
)
|
|
216
|
+
log_execution(result)
|
|
217
|
+
return result
|
|
218
|
+
|
|
219
|
+
heartbeat_task = None
|
|
220
|
+
if progress_cb is not None:
|
|
221
|
+
|
|
222
|
+
async def _heartbeat():
|
|
223
|
+
try:
|
|
224
|
+
interval = max(min(effective_timeout / 20.0, 5.0), 0.25)
|
|
225
|
+
for step in range(1, 20):
|
|
226
|
+
await asyncio.sleep(interval)
|
|
227
|
+
await _call_progress(progress_cb, min(step * 5, 95), 100)
|
|
228
|
+
except Exception: # noqa: BLE001, S110
|
|
229
|
+
pass
|
|
230
|
+
|
|
231
|
+
heartbeat_task = asyncio.create_task(_heartbeat())
|
|
232
|
+
|
|
233
|
+
try:
|
|
234
|
+
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
|
235
|
+
proc.communicate(),
|
|
236
|
+
timeout=effective_timeout,
|
|
237
|
+
)
|
|
238
|
+
except (TimeoutError, asyncio.TimeoutError):
|
|
239
|
+
logger.warning(
|
|
240
|
+
"request=%s tool=%s timed out after %ds, killing process",
|
|
241
|
+
req_id,
|
|
242
|
+
tool,
|
|
243
|
+
effective_timeout,
|
|
244
|
+
)
|
|
245
|
+
# Kill the entire process tree — the subprocess runs in
|
|
246
|
+
# its own session, so this cannot hit the server itself
|
|
247
|
+
from kali_mcp.utils.process import kill_process_tree
|
|
248
|
+
|
|
249
|
+
await kill_process_tree(proc)
|
|
250
|
+
stdout_bytes, stderr_bytes = b"", b""
|
|
251
|
+
|
|
252
|
+
elapsed = time.monotonic() - start_monotonic
|
|
253
|
+
result = ExecutionResult(
|
|
254
|
+
tool=tool,
|
|
255
|
+
command=command_str,
|
|
256
|
+
stdout="",
|
|
257
|
+
stderr=f"Timed out after {effective_timeout}s",
|
|
258
|
+
exit_code=-1,
|
|
259
|
+
success=False,
|
|
260
|
+
timed_out=True,
|
|
261
|
+
duration=round(elapsed, 3),
|
|
262
|
+
start_time=start_time,
|
|
263
|
+
end_time=utc_now_iso(),
|
|
264
|
+
)
|
|
265
|
+
log_execution(result)
|
|
266
|
+
logger.info(
|
|
267
|
+
"request=%s tool=%s TIMED OUT duration=%.3fs",
|
|
268
|
+
req_id,
|
|
269
|
+
tool,
|
|
270
|
+
result.duration,
|
|
271
|
+
)
|
|
272
|
+
return result
|
|
273
|
+
finally:
|
|
274
|
+
if heartbeat_task is not None:
|
|
275
|
+
heartbeat_task.cancel()
|
|
276
|
+
try:
|
|
277
|
+
await heartbeat_task
|
|
278
|
+
except (asyncio.CancelledError, Exception): # noqa: S110, BLE001
|
|
279
|
+
pass
|
|
280
|
+
if progress_cb is not None:
|
|
281
|
+
try:
|
|
282
|
+
await _call_progress(progress_cb, 100, 100)
|
|
283
|
+
except Exception: # noqa: BLE001, S110
|
|
284
|
+
pass
|
|
285
|
+
|
|
286
|
+
elapsed = time.monotonic() - start_monotonic
|
|
287
|
+
stdout_str = stdout_bytes.decode("utf-8", errors="replace")[:MAX_OUTPUT]
|
|
288
|
+
stderr_str = stderr_bytes.decode("utf-8", errors="replace")[:MAX_OUTPUT]
|
|
289
|
+
|
|
290
|
+
result = ExecutionResult(
|
|
291
|
+
tool=tool,
|
|
292
|
+
command=command_str,
|
|
293
|
+
stdout=stdout_str,
|
|
294
|
+
stderr=stderr_str,
|
|
295
|
+
exit_code=proc.returncode or 0,
|
|
296
|
+
success=(proc.returncode == 0),
|
|
297
|
+
timed_out=False,
|
|
298
|
+
duration=round(elapsed, 3),
|
|
299
|
+
start_time=start_time,
|
|
300
|
+
end_time=utc_now_iso(),
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
log_execution(result)
|
|
304
|
+
logger.info(
|
|
305
|
+
"request=%s tool=%s exit_code=%d success=%s duration=%.3fs",
|
|
306
|
+
req_id,
|
|
307
|
+
tool,
|
|
308
|
+
result.exit_code,
|
|
309
|
+
result.success,
|
|
310
|
+
result.duration,
|
|
311
|
+
)
|
|
312
|
+
if heartbeat_task is not None:
|
|
313
|
+
heartbeat_task.cancel()
|
|
314
|
+
try:
|
|
315
|
+
await heartbeat_task
|
|
316
|
+
except (asyncio.CancelledError, Exception): # noqa: S110, BLE001
|
|
317
|
+
pass
|
|
318
|
+
return result
|
|
319
|
+
|
|
320
|
+
finally:
|
|
321
|
+
self._cleanup_request(req_id)
|
|
322
|
+
|
|
323
|
+
except asyncio.CancelledError:
|
|
324
|
+
logger.warning("request=%s execution cancelled", req_id)
|
|
325
|
+
elapsed = time.monotonic() - start_monotonic
|
|
326
|
+
with self._lock:
|
|
327
|
+
self._error_count += 1
|
|
328
|
+
return ExecutionResult(
|
|
329
|
+
tool=tool,
|
|
330
|
+
command=command_str,
|
|
331
|
+
stdout="",
|
|
332
|
+
stderr="Execution cancelled",
|
|
333
|
+
exit_code=-1,
|
|
334
|
+
success=False,
|
|
335
|
+
timed_out=False,
|
|
336
|
+
duration=round(elapsed, 3),
|
|
337
|
+
start_time=start_time,
|
|
338
|
+
end_time=utc_now_iso(),
|
|
339
|
+
)
|
|
340
|
+
except Exception as e:
|
|
341
|
+
elapsed = time.monotonic() - start_monotonic
|
|
342
|
+
logger.exception("request=%s unexpected execution error", req_id)
|
|
343
|
+
with self._lock:
|
|
344
|
+
self._error_count += 1
|
|
345
|
+
return ExecutionResult(
|
|
346
|
+
tool=tool,
|
|
347
|
+
command=command_str,
|
|
348
|
+
stdout="",
|
|
349
|
+
stderr=f"Internal error: {type(e).__name__}",
|
|
350
|
+
exit_code=-1,
|
|
351
|
+
success=False,
|
|
352
|
+
timed_out=False,
|
|
353
|
+
duration=round(elapsed, 3),
|
|
354
|
+
start_time=start_time,
|
|
355
|
+
end_time=utc_now_iso(),
|
|
356
|
+
)
|
|
357
|
+
finally:
|
|
358
|
+
self._cleanup_request(req_id)
|
|
359
|
+
with self._lock:
|
|
360
|
+
self._request_count += 1
|
|
361
|
+
|
|
362
|
+
@property
|
|
363
|
+
def active_count(self) -> int:
|
|
364
|
+
return len(self._active)
|
|
365
|
+
|
|
366
|
+
def get_stuck_executions(self) -> list[dict]:
|
|
367
|
+
"""Return list of potentially stuck executions."""
|
|
368
|
+
now = time.monotonic()
|
|
369
|
+
stuck = []
|
|
370
|
+
for req_id, tool in list(self._active.items()):
|
|
371
|
+
start_t = self._request_times.get(req_id, now)
|
|
372
|
+
elapsed = now - start_t
|
|
373
|
+
if elapsed > config.default_timeout:
|
|
374
|
+
stuck.append(
|
|
375
|
+
{
|
|
376
|
+
"request_id": req_id,
|
|
377
|
+
"tool": tool,
|
|
378
|
+
"elapsed_seconds": round(elapsed, 1),
|
|
379
|
+
}
|
|
380
|
+
)
|
|
381
|
+
return stuck
|
|
382
|
+
|
|
383
|
+
def get_active_jobs(self) -> list[dict]:
|
|
384
|
+
"""Return a list of currently active tool executions.
|
|
385
|
+
|
|
386
|
+
Each entry: {"request_id": str, "tool": str, "elapsed_seconds": float}.
|
|
387
|
+
"""
|
|
388
|
+
now = time.monotonic()
|
|
389
|
+
with self._lock:
|
|
390
|
+
return [
|
|
391
|
+
{
|
|
392
|
+
"request_id": req_id,
|
|
393
|
+
"tool": tool,
|
|
394
|
+
"elapsed_seconds": round(now - self._request_times.get(req_id, now), 3),
|
|
395
|
+
}
|
|
396
|
+
for req_id, tool in self._active.items()
|
|
397
|
+
]
|
|
398
|
+
|
|
399
|
+
async def kill_job(self, request_id: str) -> bool:
|
|
400
|
+
"""Kill a specific active execution by request ID.
|
|
401
|
+
|
|
402
|
+
Terminates the subprocess (process tree) and removes it from tracking.
|
|
403
|
+
Returns True if the job was found and killed, False if unknown.
|
|
404
|
+
"""
|
|
405
|
+
proc = None
|
|
406
|
+
with self._lock:
|
|
407
|
+
if request_id not in self._active:
|
|
408
|
+
return False
|
|
409
|
+
proc = self._procs.pop(request_id, None)
|
|
410
|
+
self._active.pop(request_id, None)
|
|
411
|
+
self._request_times.pop(request_id, None)
|
|
412
|
+
|
|
413
|
+
if proc is not None:
|
|
414
|
+
try:
|
|
415
|
+
await kill_process_tree(proc)
|
|
416
|
+
except Exception:
|
|
417
|
+
logger.warning("monitor: failed to kill job %s", request_id, exc_info=True)
|
|
418
|
+
return True
|
|
419
|
+
|
|
420
|
+
def force_cleanup(self) -> int:
|
|
421
|
+
"""Force cleanup of all tracked requests. Returns count cleaned."""
|
|
422
|
+
count = len(self._active)
|
|
423
|
+
self._active.clear()
|
|
424
|
+
self._request_times.clear()
|
|
425
|
+
gc.collect()
|
|
426
|
+
return count
|
|
427
|
+
|
|
428
|
+
def get_stats(self) -> dict:
|
|
429
|
+
"""Get engine statistics."""
|
|
430
|
+
with self._lock:
|
|
431
|
+
return {
|
|
432
|
+
"active_executions": self.active_count,
|
|
433
|
+
"total_requests": self._request_count,
|
|
434
|
+
"total_errors": self._error_count,
|
|
435
|
+
"max_concurrent": config.max_concurrent,
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
engine = ExecutionEngine()
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Structured logging configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from logging.handlers import RotatingFileHandler
|
|
9
|
+
|
|
10
|
+
from kali_mcp.config import config
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class JSONFormatter(logging.Formatter):
|
|
14
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
15
|
+
log_entry = {
|
|
16
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
17
|
+
"level": record.levelname,
|
|
18
|
+
"logger": record.name,
|
|
19
|
+
"message": record.getMessage(),
|
|
20
|
+
}
|
|
21
|
+
if hasattr(record, "extra_data"):
|
|
22
|
+
log_entry["data"] = record.extra_data
|
|
23
|
+
return json.dumps(log_entry)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def setup_logging() -> None:
|
|
27
|
+
"""Configure structured logging to file and console with rotation."""
|
|
28
|
+
root = logging.getLogger("kali_mcp")
|
|
29
|
+
|
|
30
|
+
# Prevent duplicate handlers on re-init
|
|
31
|
+
if root.handlers:
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
root.setLevel(logging.DEBUG if config.debug else logging.INFO)
|
|
35
|
+
|
|
36
|
+
console = logging.StreamHandler()
|
|
37
|
+
console.setLevel(logging.DEBUG if config.debug else logging.INFO)
|
|
38
|
+
console.setFormatter(JSONFormatter())
|
|
39
|
+
root.addHandler(console)
|
|
40
|
+
|
|
41
|
+
log_file = config.log_dir / "server.log"
|
|
42
|
+
file_handler = RotatingFileHandler(
|
|
43
|
+
log_file,
|
|
44
|
+
maxBytes=10_000_000,
|
|
45
|
+
backupCount=5, # 10MB, 5 backups
|
|
46
|
+
)
|
|
47
|
+
file_handler.setLevel(logging.DEBUG)
|
|
48
|
+
file_handler.setFormatter(JSONFormatter())
|
|
49
|
+
root.addHandler(file_handler)
|
|
50
|
+
|
|
51
|
+
exec_file = config.log_dir / "executions.jsonl"
|
|
52
|
+
exec_handler = RotatingFileHandler(exec_file, maxBytes=10_000_000, backupCount=5)
|
|
53
|
+
exec_handler.setLevel(logging.INFO)
|
|
54
|
+
exec_handler.setFormatter(JSONFormatter())
|
|
55
|
+
exec_logger = logging.getLogger("kali_mcp.executions")
|
|
56
|
+
exec_logger.addHandler(exec_handler)
|
|
57
|
+
exec_logger.setLevel(logging.INFO)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def log_execution(result) -> None:
|
|
61
|
+
"""Log a completed execution to the executions log."""
|
|
62
|
+
try:
|
|
63
|
+
logger = logging.getLogger("kali_mcp.executions")
|
|
64
|
+
logger.info(
|
|
65
|
+
"tool=%s command=%s exit_code=%d success=%s duration=%.3fs timed_out=%s",
|
|
66
|
+
result.tool,
|
|
67
|
+
result.command,
|
|
68
|
+
result.exit_code,
|
|
69
|
+
result.success,
|
|
70
|
+
result.duration,
|
|
71
|
+
result.timed_out,
|
|
72
|
+
)
|
|
73
|
+
except Exception: # noqa: BLE001, S110
|
|
74
|
+
pass # Never let logging failure crash the handler
|
kali_mcp/models.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Core data models for execution results and errors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class ExecutionResult:
|
|
12
|
+
tool: str
|
|
13
|
+
command: str
|
|
14
|
+
stdout: str
|
|
15
|
+
stderr: str
|
|
16
|
+
exit_code: int
|
|
17
|
+
success: bool
|
|
18
|
+
timed_out: bool
|
|
19
|
+
duration: float
|
|
20
|
+
start_time: str
|
|
21
|
+
end_time: str
|
|
22
|
+
|
|
23
|
+
def to_dict(self) -> dict[str, Any]:
|
|
24
|
+
return {
|
|
25
|
+
"tool": self.tool,
|
|
26
|
+
"command": self.command,
|
|
27
|
+
"stdout": self.stdout,
|
|
28
|
+
"stderr": self.stderr,
|
|
29
|
+
"exit_code": self.exit_code,
|
|
30
|
+
"success": self.success,
|
|
31
|
+
"timed_out": self.timed_out,
|
|
32
|
+
"duration": self.duration,
|
|
33
|
+
"start_time": self.start_time,
|
|
34
|
+
"end_time": self.end_time,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class ToolError:
|
|
40
|
+
error: str
|
|
41
|
+
details: str = ""
|
|
42
|
+
|
|
43
|
+
def to_dict(self) -> dict[str, Any]:
|
|
44
|
+
d: dict[str, Any] = {"success": False, "error": self.error}
|
|
45
|
+
if self.details:
|
|
46
|
+
d["details"] = self.details
|
|
47
|
+
return d
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def utc_now_iso() -> str:
|
|
51
|
+
return datetime.now(timezone.utc).isoformat()
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Interactive monitor module for the Kali MCP Server.
|
|
2
|
+
|
|
3
|
+
Aggregates live state from the session registry, execution engine, and health
|
|
4
|
+
monitor into a single snapshot that can be pushed to a CLI/TUI client over
|
|
5
|
+
SSE or queried directly via HTTP actions.
|
|
6
|
+
|
|
7
|
+
The monitor maintains a set of SSE client queues and broadcasts snapshots to
|
|
8
|
+
all connected clients whenever state changes (session connect/disconnect, job
|
|
9
|
+
start/end, or on-demand via the action endpoint).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import logging
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from kali_mcp.models import utc_now_iso
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger("kali_mcp.monitor")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Monitor:
|
|
24
|
+
"""Live monitor aggregating sessions, active jobs, and server health."""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
session_registry: Any,
|
|
29
|
+
engine: Any,
|
|
30
|
+
health: Any,
|
|
31
|
+
) -> None:
|
|
32
|
+
self.session_registry = session_registry
|
|
33
|
+
self.engine = engine
|
|
34
|
+
self.health = health
|
|
35
|
+
self._clients: set[asyncio.Queue[dict[str, Any]]] = set()
|
|
36
|
+
self._lock = asyncio.Lock()
|
|
37
|
+
|
|
38
|
+
# ── SSE client management ──────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
def subscribe(self) -> asyncio.Queue[dict[str, Any]]:
|
|
41
|
+
"""Register a new SSE client queue. Returns the queue."""
|
|
42
|
+
q: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
|
|
43
|
+
self._clients.add(q)
|
|
44
|
+
return q
|
|
45
|
+
|
|
46
|
+
def unsubscribe(self, q: asyncio.Queue[dict[str, Any]]) -> None:
|
|
47
|
+
"""Remove an SSE client queue (client disconnected)."""
|
|
48
|
+
self._clients.discard(q)
|
|
49
|
+
|
|
50
|
+
async def broadcast(self, snapshot: dict[str, Any]) -> None:
|
|
51
|
+
"""Push a snapshot to all connected SSE clients.
|
|
52
|
+
|
|
53
|
+
Queues that are full (slow clients) are dropped to avoid blocking
|
|
54
|
+
the broadcast path.
|
|
55
|
+
"""
|
|
56
|
+
dead: list[asyncio.Queue[dict[str, Any]]] = []
|
|
57
|
+
for q in list(self._clients):
|
|
58
|
+
try:
|
|
59
|
+
q.put_nowait(snapshot)
|
|
60
|
+
except asyncio.QueueFull:
|
|
61
|
+
dead.append(q)
|
|
62
|
+
for q in dead:
|
|
63
|
+
self._clients.discard(q)
|
|
64
|
+
|
|
65
|
+
# ── Snapshot ───────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
def build_snapshot(self) -> dict[str, Any]:
|
|
68
|
+
"""Build a complete live snapshot of server state."""
|
|
69
|
+
return {
|
|
70
|
+
"timestamp": utc_now_iso(),
|
|
71
|
+
"sessions": self.session_registry.list_sessions(),
|
|
72
|
+
"jobs": self.engine.get_active_jobs(),
|
|
73
|
+
"health": self.health.get_status(),
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
# ── Actions ────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
async def kill_job(self, request_id: str) -> dict[str, Any]:
|
|
79
|
+
"""Kill a specific active execution by request ID.
|
|
80
|
+
|
|
81
|
+
Terminates the subprocess (process tree) and broadcasts the new
|
|
82
|
+
snapshot to all connected clients.
|
|
83
|
+
"""
|
|
84
|
+
killed = await self.engine.kill_job(request_id)
|
|
85
|
+
snapshot = self.build_snapshot()
|
|
86
|
+
await self.broadcast(snapshot)
|
|
87
|
+
return {
|
|
88
|
+
"action": "kill_job",
|
|
89
|
+
"request_id": request_id,
|
|
90
|
+
"killed": killed,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async def force_cleanup(self) -> dict[str, Any]:
|
|
94
|
+
"""Force cleanup all tracked executions and broadcast the snapshot."""
|
|
95
|
+
cleaned = self.engine.force_cleanup()
|
|
96
|
+
snapshot = self.build_snapshot()
|
|
97
|
+
await self.broadcast(snapshot)
|
|
98
|
+
return {
|
|
99
|
+
"action": "force_cleanup",
|
|
100
|
+
"cleaned": cleaned,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
def get_snapshot(self) -> dict[str, Any]:
|
|
104
|
+
"""Synchronous snapshot getter (for non-async callers)."""
|
|
105
|
+
return self.build_snapshot()
|