diff --git a/README.md b/README.md index 02d89df..3154d25 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,9 @@ Kompiliertes Bun-Binary (TypeScript), läuft als SysVinit-Service auf Unraid. - **MCP-Endpunkt:** `http://:3002/mcp` (Streamable HTTP, POST-only, JSON-RPC 2.0) - **Health-Check:** `http://:3002/health` (offen, ohne Auth) -- **Config-Server:** `http://127.0.0.1:3013/config` (nur localhost, für WebGUI) +- **Config-Server:** `http://127.0.0.1:3013/config` (localhost + separater Admin-Token) - **Auth:** API-Key (Bearer-Token) für `/mcp` -- **Tools:** 21 (Docker: 14, Netzwerk: 6, System: 1) +- **Tools:** 22 (Docker: 14, Netzwerk: 6, System: 2) --- @@ -30,14 +30,30 @@ Kompiliertes Bun-Binary (TypeScript), läuft als SysVinit-Service auf Unraid. │ │ ┌────────┴─────────┐ ┌────────┴─────────┐ │ MCP-Client │ │ Unraid WebGUI │ -│ (Hermes, etc.) │ │ Settings → MUA │ +│ (Hermes, etc.) │ │ User Utilities → MUA │ └──────────────────┘ └──────────────────┘ ``` **Wichtig:** Der MCP-Endpunkt (Port 3002) ist **extern** erreichbar und braucht einen Bearer-Token. Der Config-Server (Port 3013) läuft **nur auf localhost** -und wird ausschließlich von der WebGUI-Page (`mua.page`) über PHP/curl -angesprochen — er ist von außen nicht erreichbar. +und verlangt zusätzlich einen bei jedem Start neu erzeugten Admin-Token. +Die WebGUI schützt schreibende Formulare außerdem mit einem CSRF-Token. + +## Sicherheitsmodell + +- Neuinstallationen starten im Profil **Nur Lesen**. Container-Steuerung, + aktive Netzwerktests, Container-Umbauten und die Root-Shell sind aus. +- `none` bedeutet tatsächlich **keine Tools aktiv**; `all` ist ein expliziter + Vollzugriff und wird in der GUI deutlich gewarnt. +- Container-Umgebungswerte werden nie ausgegeben, nur ihre Variablennamen. +- Häufige Secret-Formate in Logs werden zusätzlich redigiert. Logs können + trotzdem Nutzdaten enthalten und sollten gezielt abgefragt werden. +- Der API-Key erscheint nur unmittelbar nach seiner Erzeugung vollständig. +- Tool-Aufrufe werden ohne Argumente oder Ausgaben in + `/var/log/plugins/mua-audit.log` protokolliert. +- CORS ist standardmäßig aus. Requests sind auf 1 MiB, 120 pro Minute je + Client und vier gleichzeitig laufende Werkzeuge begrenzt. Diese Grenzen + lassen sich per Umgebungsvariable anpassen. --- @@ -52,10 +68,10 @@ Oder manuell: ```bash # .txz von Gitea laden -curl -O http://192.168.1.2:4000/michael/MUA-Mikes-Unraid-Agent/raw/branch/main/dist/mua-2026.08.18.r005-x86_64-1.txz +curl -O http://192.168.1.2:4000/michael/MUA-Mikes-Unraid-Agent/raw/branch/main/dist/mua-2026.08.21.r007-x86_64-1.txz # Installieren -./mua-2026.08.18.r005-x86_64-1.txz +upgradepkg --install-new mua-2026.08.21.r007-x86_64-1.txz ``` ### 2. Service starten @@ -70,16 +86,16 @@ Der Service startet automatisch bei jedem Boot (SysVinit). ```bash curl http://192.168.1.2:3002/health -# → {"status":"ok","version":"2026.08.18.r005","auth":"required"} +# → {"status":"ok","version":"2026.08.21.r007","auth":"required"} ``` --- ## API-Key generieren (WebGUI) -1. **Settings → MUA** in der Unraid-WebGUI öffnen +1. **Settings → User Utilities → MUA** in der Unraid-WebGUI öffnen 2. Unter **„API-Key (Authentifizierung)"** auf **„Neuen API-Key generieren"** klicken -3. Den angezeigten Key kopieren (64-stellige Hex-String) +3. Den einmalig angezeigten Key kopieren (64-stellige Hex-Zeichenfolge) Der Key wird in `/boot/config/plugins/mua/mua.conf` gespeichert (chmod 600). @@ -177,13 +193,20 @@ curl -X POST http://192.168.1.2:3002/mcp \ ## Tools aktivieren/deaktivieren (WebGUI) -In **Settings → MUA** → **„Tools (aktivieren / deaktivieren)"**: +In **Settings → User Utilities → MUA** stehen vier Sicherheitsprofile bereit: + +- **Nur Lesen (empfohlen):** Status, Diagnose, Logs und Inventar +- **Betrieb + Diagnose:** zusätzlich aktive Prüfungen und Start/Stop/Restart +- **Alles sperren:** MCP bleibt erreichbar, bietet aber keine Werkzeuge an +- **Vollzugriff:** einschließlich Container-Umbau und uneingeschränkter Root-Shell + +Zusätzlich kann jedes Werkzeug einzeln nach Risikostufe freigegeben werden: | Gruppe | Tools | |--------|-------| | **Docker (14)** | `unraid_docker_list`, `unraid_docker_inspect`, `unraid_docker_logs`, `unraid_docker_analyze_logs`, `unraid_docker_processes`, `unraid_docker_stats`, `unraid_docker_info`, `unraid_docker_start`, `unraid_docker_stop`, `unraid_docker_restart`, `unraid_docker_create`, `unraid_docker_modify`, `unraid_docker_update`, `unraid_docker_rebuild` | | **Netzwerk (6)** | `unraid_network_inventory`, `unraid_network_list`, `unraid_network_inspect`, `unraid_network_host_state`, `unraid_network_audit_tcp`, `unraid_network_lan_probe` | -| **System (1)** | `unraid_system_connection_test` | +| **System (2)** | `unraid_system_connection_test`, `unraid_system_shell` | Deaktivierte Tools werden vom MCP-Server gefiltert — sie erscheinen nicht in `tools/list` und können nicht aufgerufen werden (→ `ERROR: Tool disabled`). @@ -220,18 +243,13 @@ Prozess startet automatisch. Zusätzlich führt der POST-INSTALL-Hook im ### Config-Datei-Format (`mua.conf`) -```json -{ - "apiKey": "a1b2c3d4...", - "enabledTools": [ - "unraid_docker_list", - "unraid_docker_inspect", - "unraid_network_list", - "unraid_system_connection_test" - ] -} +```ini +MUA_API_KEY=a1b2c3d4... +MUA_ENABLED_TOOLS=unraid_docker_list,unraid_docker_inspect,unraid_network_list ``` +Sonderwerte: `all` aktiviert ausdrücklich alles, `none` deaktiviert alles. + --- ## Entwicklung diff --git a/dist/mua-2026.08.21.r007-x86_64-1.txz b/dist/mua-2026.08.21.r007-x86_64-1.txz new file mode 100644 index 0000000..201c6e5 Binary files /dev/null and b/dist/mua-2026.08.21.r007-x86_64-1.txz differ diff --git a/package.json b/package.json index a55bdf7..ed75406 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mua", - "version": "2026.08.18.r006", + "version": "2026.08.21.r007", "description": "Mikes Unraid Agent - MCP over HTTP (Streamable HTTP) for Unraid", "type": "module", "main": "src/index.ts", diff --git a/plugin/mua.plg b/plugin/mua.plg index a50f5cf..5fd510f 100644 --- a/plugin/mua.plg +++ b/plugin/mua.plg @@ -2,13 +2,13 @@ - - + + - - + + ]> +### 2026.08.21.r007 +- Navigation: MUA liegt unter Settings → User Utilities; der Klick im Plugin-Manager öffnet zuverlässig die MUA-Seite (korrekte Groß-/Kleinschreibung). +- Sicherheitsprofile: Nur Lesen (Standard), Betrieb + Diagnose, Alles sperren und expliziter Vollzugriff. +- Fix: Eine leere Tool-Auswahl aktiviert nicht mehr versehentlich alle Tools. +- Secrets: Container-Umgebungswerte werden nicht mehr ausgegeben; häufige Secrets in Logs werden redigiert. +- API-Key: nur einmalige Klartextanzeige nach Rotation; danach ausschließlich maskiert. +- Config-Server: zusätzlicher, pro Start erneuerter Admin-Token und CSRF-Schutz in der WebGUI. +- Netzwerk: CORS standardmäßig aus, Request-Limit 1 MiB und Rate-Limit pro Client. +- Audit: Werkzeugname, Risikostufe, Ergebnis und Laufzeit ohne Argumente/Inhalte in mua-audit.log. +- WebGUI vollständig neu geordnet: Statuskarten, Sicherheitsprofile und Werkzeuge nach Risikostufe. ### 2026.08.18.r006 - Neues Tool: unraid_system_shell — direkter Shell-Zugriff auf den Unraid-Host (root, /bin/sh -c). Liefert exit_code + stdout + stderr (JSON). - WebGUI: unraid_system_shell in der System-Tool-Gruppe (jetzt 2 System-Tools, insgesamt 22 Tools). @@ -81,7 +91,7 @@ Das .txz enthält: install/doinst.sh (läuft nach Installation) =========================================== --> - + &txzURL; &txzSHA256; @@ -113,7 +123,7 @@ if curl -sf "http://127.0.0.1:3002/health" > /dev/null 2>&1; then echo "====================================================" echo " MUA installiert und läuft auf Port 3002" echo " Endpunkt: http://$(hostname -I | awk '{print $1}'):3002/mcp" - echo " WebGUI: Settings > MUA" + echo " WebGUI: Settings > User Utilities > MUA" echo "====================================================" else echo "" @@ -179,7 +189,9 @@ rm -rf &pluginLOC; # 7. Logs entfernen rm -f /var/log/plugins/mua.log +rm -f /var/log/plugins/mua-audit.log rm -f /var/run/mua.pid +rm -f /var/run/mua-admin.token echo "" echo " MUA wurde deinstalliert." diff --git a/scripts/mua.page b/scripts/mua.page index f0f55d6..d5f4719 100644 --- a/scripts/mua.page +++ b/scripts/mua.page @@ -1,306 +1,173 @@ --- -Menu="OtherSettings" +Menu="Utilities" Type="xmenu" Title="MUA" Icon="cubes" --- true, CURLOPT_TIMEOUT => 5, CURLOPT_CONNECTTIMEOUT => 2, CURLOPT_HTTPHEADER => $headers]; + if ($method === 'POST') { + $options[CURLOPT_POST] = true; + if ($payload !== null) { + $options[CURLOPT_POSTFIELDS] = json_encode($payload); + $headers[] = 'Content-Type: application/json'; + $options[CURLOPT_HTTPHEADER] = $headers; + } + } + curl_setopt_array($ch, $options); + $raw = curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + return [$code, $raw !== false ? json_decode($raw, true) : null]; +} + +function csrf_value($token, $offset = 0) { + return hash_hmac('sha256', gmdate('Y-m-d-H', time() + $offset * 3600), $token); +} + +function mua_health($port) { + $ch = curl_init('http://127.0.0.1:' . $port . '/health'); + curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 3, CURLOPT_CONNECTTIMEOUT => 2]); + $raw = curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + return $code === 200 && $raw !== false ? json_decode($raw, true) : null; +} -// ── Status bestimmen ──────────────────────────────────────────────────── $running = false; $pid = null; if (file_exists($pidfile)) { $pid = trim(file_get_contents($pidfile)); - if ($pid && is_numeric($pid) && @posix_kill((int)$pid, 0)) { - $running = true; - } + $running = $pid && is_numeric($pid) && @posix_kill((int)$pid, 0); } -// ── Health-Check (MCP-Server, Port 3002) ──────────────────────────────── -$health = null; -$health_code = 0; -$ch = curl_init('http://127.0.0.1:' . $port . '/health'); -curl_setopt_array($ch, [ - CURLOPT_RETURNTRANSFER => true, - CURLOPT_TIMEOUT => 3, - CURLOPT_CONNECTTIMEOUT => 2, -]); -$health = curl_exec($ch); -$health_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); -curl_close($ch); -$health_ok = ($health_code === 200); - -// Version aus /health lesen (dynamisch, kein hartkodiertes rNNN) -$running_version = ''; -if ($health_ok && $health !== false) { - $h = json_decode($health, true); - $running_version = $h['version'] ?? ''; -} - -// ── Config vom Config-Server laden (Port 3003) ────────────────────────── -$config = null; -$ch = curl_init('http://127.0.0.1:' . $config_port . '/config'); -curl_setopt_array($ch, [ - CURLOPT_RETURNTRANSFER => true, - CURLOPT_TIMEOUT => 3, - CURLOPT_CONNECTTIMEOUT => 2, -]); -$config_raw = curl_exec($ch); -$config_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); -curl_close($ch); -if ($config_code === 200 && $config_raw !== false) { - $config = json_decode($config_raw, true); -} - -$api_key = $config['apiKey'] ?? ''; -$enabled_tools = $config['enabledTools'] ?? []; -$all_tools = $config['allTools'] ?? []; -$config_ok = ($config !== null); - -// ── POST-Handling (Einstellungen speichern) ───────────────────────────── +$health_data = mua_health($port); +$health_ok = is_array($health_data) && ($health_data['status'] ?? '') === 'ok'; +$running_version = $health_data['version'] ?? ''; +[$config_code, $config] = mua_api('GET', '/config', null, $admin_token, $config_port); +$config_ok = $config_code === 200 && is_array($config); $flash_msg = ''; $flash_type = ''; +$new_api_key = ''; +$csrf = csrf_value($admin_token); + +$readonly = [ + 'unraid_docker_list', 'unraid_docker_inspect', 'unraid_docker_logs', + 'unraid_docker_analyze_logs', 'unraid_docker_processes', 'unraid_docker_stats', + 'unraid_docker_info', 'unraid_network_inventory', 'unraid_network_list', + 'unraid_network_inspect', 'unraid_network_host_state', 'unraid_system_connection_test', +]; +$operator = array_merge($readonly, [ + 'unraid_network_audit_tcp', 'unraid_network_lan_probe', + 'unraid_docker_start', 'unraid_docker_stop', 'unraid_docker_restart', +]); + if ($_SERVER['REQUEST_METHOD'] === 'POST') { - $payload = []; - - // A) API-Key generieren - if (isset($_POST['action']) && $_POST['action'] === 'generate_key') { - $payload['generate'] = true; - } - - // B) Tool-Checkboxen - if (isset($_POST['action']) && $_POST['action'] === 'save_tools') { - $selected = []; - foreach ($all_tools as $tool) { - if (isset($_POST['tool_' . $tool])) { - $selected[] = $tool; + $posted_csrf = $_POST['mua_csrf'] ?? ''; + $csrf_ok = $admin_token && (hash_equals(csrf_value($admin_token), $posted_csrf) || hash_equals(csrf_value($admin_token, -1), $posted_csrf)); + if (!$csrf_ok) { + $flash_msg = 'Sicherheitsprüfung fehlgeschlagen. Seite neu laden und erneut versuchen.'; + $flash_type = 'err'; + } else { + $action = $_POST['action'] ?? ''; + $payload = null; + if ($action === 'generate_key') $payload = ['generate' => true]; + if ($action === 'preset_readonly') $payload = ['enabledTools' => $readonly, 'allToolsEnabled' => false]; + if ($action === 'preset_operator') $payload = ['enabledTools' => $operator, 'allToolsEnabled' => false]; + if ($action === 'preset_none') $payload = ['enabledTools' => [], 'allToolsEnabled' => false]; + if ($action === 'preset_all') $payload = ['enabledTools' => [], 'allToolsEnabled' => true]; + if ($action === 'save_tools') { + $selected = []; + foreach (($config['allTools'] ?? []) as $tool) { + $name = is_array($tool) ? ($tool['name'] ?? '') : $tool; + if ($name && isset($_POST['tool_' . $name])) $selected[] = $name; } + $payload = ['enabledTools' => $selected, 'allToolsEnabled' => false]; } - $payload['enabledTools'] = $selected; - } - // C) Service neu starten - if (isset($_POST['action']) && $_POST['action'] === 'restart') { - $ch = curl_init('http://127.0.0.1:' . $config_port . '/restart'); - curl_setopt_array($ch, [ - CURLOPT_RETURNTRANSFER => true, - CURLOPT_TIMEOUT => 5, - CURLOPT_CONNECTTIMEOUT => 3, - CURLOPT_POST => true, - ]); - $resp_raw = curl_exec($ch); - $resp_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - if ($resp_code === 200) { - $flash_msg = 'MUA wird neu gestartet (Service restart).'; - $flash_type = 'ok'; - } else { - $flash_msg = 'Fehler beim Neustart (HTTP ' . $resp_code . ')'; - $flash_type = 'err'; - } - } - - if (!empty($payload)) { - $ch = curl_init('http://127.0.0.1:' . $config_port . '/config'); - curl_setopt_array($ch, [ - CURLOPT_RETURNTRANSFER => true, - CURLOPT_TIMEOUT => 5, - CURLOPT_CONNECTTIMEOUT => 3, - CURLOPT_POST => true, - CURLOPT_POSTFIELDS => json_encode($payload), - CURLOPT_HTTPHEADER => ['Content-Type: application/json'], - ]); - $resp_raw = curl_exec($ch); - $resp_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - $resp = json_decode($resp_raw, true); - - if ($resp_code === 200 && isset($resp['ok'])) { - // Config neu laden - $ch = curl_init('http://127.0.0.1:' . $config_port . '/config'); - curl_setopt_array($ch, [ - CURLOPT_RETURNTRANSFER => true, - CURLOPT_TIMEOUT => 3, - CURLOPT_CONNECTTIMEOUT => 2, - ]); - $config_raw = curl_exec($ch); - curl_close($ch); - $config = json_decode($config_raw, true); - $api_key = $config['apiKey'] ?? ''; - $enabled_tools = $config['enabledTools'] ?? []; - - if ($payload['generate'] ?? false) { - $flash_msg = 'Neuer API-Key generiert.'; + if ($action === 'restart') { + [$code, $result] = mua_api('POST', '/restart', null, $admin_token, $config_port); + $flash_msg = $code === 200 ? 'MUA wird neu gestartet.' : 'Neustart fehlgeschlagen (HTTP ' . $code . ').'; + $flash_type = $code === 200 ? 'ok' : 'err'; + } elseif ($payload !== null) { + [$code, $result] = mua_api('POST', '/config', $payload, $admin_token, $config_port); + if ($code === 200 && is_array($result) && ($result['ok'] ?? false)) { + $new_api_key = $result['generatedApiKey'] ?? ''; + $flash_msg = $new_api_key ? 'Neuer API-Key erzeugt. Jetzt kopieren – er wird nur dieses eine Mal angezeigt.' : 'Sicherheitseinstellungen gespeichert.'; $flash_type = 'ok'; + [$config_code, $config] = mua_api('GET', '/config', null, $admin_token, $config_port); + $config_ok = $config_code === 200 && is_array($config); } else { - $flash_msg = 'Tool-Einstellungen gespeichert (' . count($selected) . ' von ' . count($all_tools) . ' aktiv).'; - $flash_type = 'ok'; + $flash_msg = 'Speichern fehlgeschlagen (HTTP ' . $code . ').'; + $flash_type = 'err'; } - } else { - $flash_msg = 'Fehler beim Speichern (HTTP ' . $resp_code . ')'; - $flash_type = 'err'; } } } -$binary_exists = file_exists($binary); -$host_ip = $_SERVER['SERVER_ADDR'] ?? '127.0.0.1'; - -// Tool-Gruppierung für die Checkbox-Liste -$tool_groups = [ - 'Docker' => [ - 'unraid_docker_list', 'unraid_docker_inspect', 'unraid_docker_logs', - 'unraid_docker_analyze_logs', 'unraid_docker_processes', 'unraid_docker_stats', - 'unraid_docker_info', 'unraid_docker_start', 'unraid_docker_stop', - 'unraid_docker_restart', 'unraid_docker_create', 'unraid_docker_modify', - 'unraid_docker_update', 'unraid_docker_rebuild', - ], - 'Netzwerk' => [ - 'unraid_network_inventory', 'unraid_network_list', 'unraid_network_inspect', - 'unraid_network_host_state', 'unraid_network_audit_tcp', 'unraid_network_lan_probe', - ], - 'System' => [ - 'unraid_system_connection_test', - 'unraid_system_shell', - ], +$enabled_tools = $config['enabledTools'] ?? []; +$all_tools_enabled = (bool)($config['allToolsEnabled'] ?? false); +$all_tools = $config['allTools'] ?? []; +$risk_groups = ['read' => [], 'active' => [], 'write' => [], 'critical' => []]; +foreach ($all_tools as $tool) { + if (!is_array($tool)) $tool = ['name' => $tool, 'description' => '', 'risk' => 'read']; + $risk = $tool['risk'] ?? 'read'; + if (!isset($risk_groups[$risk])) $risk = 'read'; + $risk_groups[$risk][] = $tool; +} +$risk_labels = [ + 'read' => ['Nur Lesen', 'Liest Status und Diagnoseinformationen.', 'safe'], + 'active' => ['Aktive Prüfungen', 'Baut gezielt Netzwerkverbindungen für Tests auf.', 'notice'], + 'write' => ['Betrieb steuern', 'Startet, stoppt oder startet Container neu.', 'warn'], + 'critical' => ['Kritischer Zugriff', 'Verändert Container oder führt uneingeschränkte Root-Befehle aus.', 'danger'], ]; +$active_count = $all_tools_enabled ? count($all_tools) : count($enabled_tools); ?> +
+

MUA · Mikes Unraid Agent

Sicherer MCP-Zugriff auf Docker, Netzwerk und Unraid-Systemfunktionen
- -
✅ MCP-Server läuft (PID , Port )
- -
❌ MCP-Server läuft nicht — starte mit /etc/rc.d/rc.mua start
- +
+
Neuer API-Key – einmalige Anzeige
- -
✅ Health-Check: OK
- -
⚠️ Health-Check: HTTP
- -
⚠️ Health-Check: nicht erreichbar
- +
+

Dienst

Version · PID
+

Zugriffsprofil

von Werkzeugen aktiv

+

Authentifizierung

+
- -
- +

MCP-Endpunkt

Streamable HTTP · JSON-RPC 2.0 · Bearer-Authentifizierung erforderlich

-

MCP-Endpunkt

-

-

Transport: Streamable HTTP (POST-only) · JSON-RPC 2.0

+

Schnelle Sicherheitsprofile

„Nur Lesen“ ist die empfohlene Dauerstellung. Weitergehende Rechte nur bei konkretem Bedarf freigeben.

+ +
">
+
-

API-Key (Authentifizierung)

- -
✅ API-Key aktiv — Clients müssen diesen als Bearer-Token senden
-

API-Key:***

-

Authorization-Header: Authorization: Bearer *** htmlspecialchars($api_key) ?>

-

- Beispiel (curl):
- curl -X POST -H "Authorization: Bearer *** htmlspecialchars($api_key) ?>" -H "Content-Type: application/json" -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' -

-
- - -
- -
⚠️ Kein API-Key gesetzt. Generiere einen, um den MCP-Endpunkt zu schützen.
-
- - -
- -
⚠️ Config-Server nicht erreichbar (Port ). Starte den MCP-Server, um die Einstellungen zu verwalten.
- +

Werkzeuge im Detail

+ $tools): $meta=$risk_labels[$risk]; ?> +

+ + +
+
-

Tools (aktivieren / deaktivieren)

- -

- . - Deaktivierte Tools werden nicht in tools/list angezeigt und bei tools/call abgelehnt. -

-
- - $group_tools): ?> -
-

()

- - - - -
- -
- -
-
- -
⚠️ Tool-Liste nicht verfügbar (Config-Server nicht erreichbar).
- +

API-Key

Der vollständige Schlüssel wird nicht dauerhaft angezeigt. Beim Erzeugen erscheint er genau einmal; danach ist nur noch die Endung sichtbar.

-

Details

- - - - - - - - - - - -
PluginMUA (Mikes Unraid Agent)
Version
RuntimeTypeScript (Bun Runtime, kompiliertes Binary)
Binary
Port (MCP)
Port (Config) (nur localhost)
Host-IP
Tools (Docker: 14, Netzwerk: 6, System: 2)
Plugin-Verzeichnis
Service/etc/rc.d/rc.mua (SysVinit)
- -

Verwaltung

- - - - - - - -
AktionBefehl
Starten/etc/rc.d/rc.mua start
Stoppen/etc/rc.d/rc.mua stop
Neu starten/etc/rc.d/rc.mua restart
Status/etc/rc.d/rc.mua status
Logstail -f /var/log/plugins/mua.log
- -

Service neu starten

-

Startet den MCP-Service neu (z. B. nach einem Plugin-Update, wenn sich das Binary geändert hat).

-
- - -
+

Technische Details

Version
MCP-Port
Verwaltung127.0.0.1: · separater Admin-Token
Konfiguration/boot/config/plugins/mua/mua.conf
Audit-Protokoll/var/log/plugins/mua-audit.log (keine Argumente oder Inhalte)
+
diff --git a/scripts/package.sh b/scripts/package.sh index 6f19650..6841465 100755 --- a/scripts/package.sh +++ b/scripts/package.sh @@ -34,12 +34,11 @@ echo " MUA - Paket-Builder" echo " Version: ${VERSION}" echo "====================================================" -# 1. Binary bauen (falls nicht vorhanden) -if [ ! -f "${DIST_DIR}/mua" ]; then - echo " Kompiliere Binary..." - cd "${ROOT}" - bun build src/index.ts --compile --target=bun-linux-x64 --outfile "${DIST_DIR}/mua" -fi +# 1. Binary immer frisch bauen. Ein vorhandenes Binary darf niemals +# versehentlich in ein neues Sicherheits-Release übernommen werden. +echo " Kompiliere Binary..." +cd "${ROOT}" +bun build src/index.ts --compile --target=bun-linux-x64 --outfile "${DIST_DIR}/mua" chmod +x "${DIST_DIR}/mua" echo " Binary: $(du -h "${DIST_DIR}/mua" | cut -f1)" @@ -74,6 +73,8 @@ cat > "${BUILD_DIR}/install/doinst.sh" << 'DOEOF' # Verzeichnisse mkdir -p /var/log/plugins touch /var/log/plugins/mua.log +touch /var/log/plugins/mua-audit.log +chmod 600 /var/log/plugins/mua.log /var/log/plugins/mua-audit.log mkdir -p /boot/config/plugins/mua # Service starten @@ -99,12 +100,12 @@ cat > "${BUILD_DIR}/install/slack-desc" << DESCEOF | |${PKG_NAME} - Mikes Unraid Agent |MCP over HTTP (Streamable HTTP) Server für Unraid. -|21 Tools: Docker (14), Netzwerk (6), System (1). +|22 Tools: Docker (14), Netzwerk (6), System (2). |Port: 3002, Endpunkt: /mcp | |Runtime: TypeScript (Bun Runtime, kompiliertes Binary) |Service: /etc/rc.d/rc.mua (SysVinit) -|WebGUI: Settings > MUA +|WebGUI: Settings > User Utilities > MUA | |Version: ${VERSION} |Arch: ${ARCH} diff --git a/scripts/rc.mua b/scripts/rc.mua index 87804de..39a93fb 100644 --- a/scripts/rc.mua +++ b/scripts/rc.mua @@ -47,6 +47,7 @@ stop() { echo " MUA gestoppt (PID $pid)" fi rm -f "$PIDFILE" + rm -f /var/run/mua-admin.token else # Fallback: per Port suchen local pid=$(lsof -ti :$PORT 2>/dev/null) @@ -56,6 +57,7 @@ stop() { else echo " MUA läuft nicht" fi + rm -f /var/run/mua-admin.token fi } diff --git a/src/auth.ts b/src/auth.ts index 2bb0c40..f7bcaba 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -5,7 +5,7 @@ * Config-Datei: /boot/config/plugins/mua/mua.conf (chmod 600) * Format (INI): * MUA_API_KEY=<64 hex chars> - * MUA_ENABLED_TOOLS=all | + * MUA_ENABLED_TOOLS=all | none | * * Auth-Flow: * Client sendet: Authorization: Bearer *** @@ -27,16 +27,39 @@ const CONFIG_DIR = process.env["MUA_CONFIG_DIR"] ?? "/boot/config/plugins/mua"; const CONFIG_FILE = `${CONFIG_DIR}/mua.conf`; // ── Config-Struktur ───────────────────────────────────────────────────── -interface MUAConfig { +export interface MUAConfig { apiKey: string; - enabledTools: string[]; // leer = alle aktiv + enabledTools: string[]; + allToolsEnabled: boolean; } +// Sichere Grundeinstellung für Neuinstallationen. Schreibzugriffe, aktive +// Netzwerkscans und die Root-Shell müssen ausdrücklich in der GUI aktiviert +// werden. +export const SAFE_DEFAULT_TOOLS = [ + "unraid_docker_list", + "unraid_docker_inspect", + "unraid_docker_logs", + "unraid_docker_analyze_logs", + "unraid_docker_processes", + "unraid_docker_stats", + "unraid_docker_info", + "unraid_network_inventory", + "unraid_network_list", + "unraid_network_inspect", + "unraid_network_host_state", + "unraid_system_connection_test", +]; + // ── Config laden ──────────────────────────────────────────────────────── let cachedConfig: MUAConfig | null = null; -function parseConfig(content: string): MUAConfig { - const cfg: MUAConfig = { apiKey: "", enabledTools: [] }; +export function parseConfig(content: string): MUAConfig { + const cfg: MUAConfig = { + apiKey: "", + enabledTools: [...SAFE_DEFAULT_TOOLS], + allToolsEnabled: false, + }; for (const line of content.split("\n")) { const trimmed = line.trim(); if (trimmed.startsWith("#") || trimmed === "") continue; @@ -47,13 +70,18 @@ function parseConfig(content: string): MUAConfig { if (key === "MUA_API_KEY") { cfg.apiKey = value; } else if (key === "MUA_ENABLED_TOOLS") { - if (value === "all" || value === "") { + if (value === "all") { cfg.enabledTools = []; + cfg.allToolsEnabled = true; + } else if (value === "none" || value === "") { + cfg.enabledTools = []; + cfg.allToolsEnabled = false; } else { cfg.enabledTools = value .split(",") .map((s) => s.trim()) .filter((s) => s.length > 0); + cfg.allToolsEnabled = false; } } } @@ -66,7 +94,10 @@ function loadConfig(): MUAConfig { // 1. Env-Override (für Testing / Docker) const envToken = process.env["MUA_API_KEY"]; if (envToken && envToken.length > 0) { - cachedConfig = { apiKey: envToken, enabledTools: [] }; + const envTools = process.env["MUA_ENABLED_TOOLS"] ?? ""; + cachedConfig = parseConfig( + `MUA_API_KEY=${envToken}\nMUA_ENABLED_TOOLS=${envTools || "none"}\n`, + ); return cachedConfig; } @@ -84,7 +115,11 @@ function loadConfig(): MUAConfig { // 3. Neue Config generieren const newToken = randomBytes(32).toString("hex"); // 64 hex chars - cachedConfig = { apiKey: newToken, enabledTools: [] }; + cachedConfig = { + apiKey: newToken, + enabledTools: [...SAFE_DEFAULT_TOOLS], + allToolsEnabled: false, + }; try { mkdirSync(CONFIG_DIR, { recursive: true }); @@ -92,7 +127,7 @@ function loadConfig(): MUAConfig { console.log(`[MUA] Neue Config generiert: ${CONFIG_FILE}`); } catch (e) { console.error(`[MUA] WARNUNG: Config konnte nicht gespeichert werden: ${String(e)}`); - console.error(`[MUA] API-Key (nur in diesem Log): ${newToken}`); + console.error("[MUA] Aus Sicherheitsgründen wird der flüchtige API-Key nicht protokolliert."); } return cachedConfig; @@ -100,17 +135,18 @@ function loadConfig(): MUAConfig { // ── Config schreiben ──────────────────────────────────────────────────── function writeConfigFile(cfg: MUAConfig): void { - const toolsLine = - cfg.enabledTools.length === 0 - ? "MUA_ENABLED_TOOLS=all" - : `MUA_ENABLED_TOOLS=${cfg.enabledTools.join(",")}`; + const toolsValue = cfg.allToolsEnabled + ? "all" + : cfg.enabledTools.length === 0 + ? "none" + : cfg.enabledTools.join(","); const content = [ "# MUA Configuration", "# API-Key für MCP-HTTP-Endpunkt (Port 3002)", "# Format: Authorization: Bearer ***", `MUA_API_KEY=${cfg.apiKey}`, - "# Aktive Tools (all = alle, oder kommagetrennte Tool-Namen)", - toolsLine, + "# Aktive Tools (all = alle, none = keine, oder kommagetrennte Tool-Namen)", + `MUA_ENABLED_TOOLS=${toolsValue}`, "", ].join("\n"); writeFileSync(CONFIG_FILE, content, { mode: 0o600 }); @@ -148,13 +184,18 @@ export function getEnabledTools(): string[] { return loadConfig().enabledTools; } +export function getAllToolsEnabled(): boolean { + return loadConfig().allToolsEnabled; +} + /** * Setzt die aktiven Tools. - * Leer-Array = alle Tools aktiv. + * Ein leeres Array bedeutet ausdrücklich: keine Tools aktiv. */ -export function setEnabledTools(names: string[]): void { +export function setEnabledTools(names: string[], allToolsEnabled = false): void { const cfg = loadConfig(); - cfg.enabledTools = names; + cfg.enabledTools = [...new Set(names)]; + cfg.allToolsEnabled = allToolsEnabled; saveConfig(cfg); } @@ -164,7 +205,7 @@ export function setEnabledTools(names: string[]): void { */ export function isToolEnabled(toolName: string): boolean { const enabled = getEnabledTools(); - if (enabled.length === 0) return true; // alle aktiv + if (getAllToolsEnabled()) return true; return enabled.includes(toolName); } @@ -192,7 +233,7 @@ export function checkAuth(req: Request): boolean { return diff === 0; } -// ── Token für WebGUI-Tab (nur lesbar) ─────────────────────────────────── -export function getTokenForDisplay(): string { - return getApiKey(); +export function getMaskedApiKey(): string { + const key = getApiKey(); + return key.length >= 8 ? `••••••••${key.slice(-8)}` : "gesetzt"; } diff --git a/src/helpers.ts b/src/helpers.ts index 5711687..5f9e83c 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -13,7 +13,7 @@ import { createConnection, type Socket } from "net"; // ── Konstanten ────────────────────────────────────────────────────────── export const MUA_SERVER_NAME = "mua"; -export const MUA_VERSION = "2026.08.18.r006"; +export const MUA_VERSION = "2026.08.21.r007"; export const MUA_PROTOCOL_VERSION = "2025-03-26"; export const PHP_HELPER = "/usr/local/bin/unraid-docker-mcp-helper.php"; @@ -224,6 +224,17 @@ export function sanitizeLogOutput(text: string, maxChars = 50000): string { let result = text.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, ""); // Entferne andere Control-Chars (außer \n, \r, \t) result = result.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, ""); + // Häufige Secret-Formate redigieren. Das ist bewusst nur eine zusätzliche + // Schutzschicht; Container-Logs können weiterhin sensible Nutzdaten + // enthalten und sollten nur gezielt sowie mit kleinem `tail` gelesen werden. + result = result.replace( + /((?:api[_-]?key|token|secret|password|passwd|authorization|cookie)\s*[=:]\s*)([^\s,;]+)/gi, + "$1[REDACTED]", + ); + result = result.replace( + /(\"(?:api[_-]?key|token|secret|password|passwd|authorization|cookie)\"\s*:\s*\")[^\"]*(\")/gi, + "$1[REDACTED]$2", + ); // Begrenze Länge if (result.length > maxChars) { result = "... [truncated] ..." + result.slice(-maxChars); @@ -254,7 +265,11 @@ export async function compactContainerInspect(container: string): Promise + typeof entry === "string" ? entry.split("=", 1)[0] : "", + ).filter((name: string) => name !== ""), Mounts: (d.Mounts ?? []).map((m: any) => ({ Type: m.Type ?? "", Source: m.Source ?? "", diff --git a/src/index.ts b/src/index.ts index 04654c6..cb0454d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,14 +18,27 @@ import { MUA_VERSION, MUA_PROTOCOL_VERSION, } from "./helpers"; -import { TOOLS, toolByName } from "./tools"; -import { checkAuth, isToolEnabled, getApiKey, getEnabledTools, setApiKey, setEnabledTools } from "./auth"; +import { TOOLS, toolByName, getToolRisk } from "./tools"; +import { + checkAuth, + isToolEnabled, + getEnabledTools, + getAllToolsEnabled, + getMaskedApiKey, + generateApiKey, + setEnabledTools, +} from "./auth"; import { randomBytes } from "node:crypto"; import { spawn } from "node:child_process"; -import { statSync } from "node:fs"; +import { appendFile, chmodSync, statSync, writeFileSync } from "node:fs"; const PORT = Number(process.env["MUA_PORT"] ?? 3002); const HOST = process.env["MUA_HOST"] ?? "0.0.0.0"; +const MAX_REQUEST_BYTES = Number(process.env["MUA_MAX_REQUEST_BYTES"] ?? 1_048_576); +const RATE_LIMIT_PER_MINUTE = Number(process.env["MUA_RATE_LIMIT_PER_MINUTE"] ?? 120); +const MAX_CONCURRENT_TOOL_CALLS = Number(process.env["MUA_MAX_CONCURRENT_TOOL_CALLS"] ?? 4); +const CORS_ORIGIN = process.env["MUA_CORS_ORIGIN"] ?? ""; +const AUDIT_LOG = process.env["MUA_AUDIT_LOG"] ?? "/var/log/plugins/mua-audit.log"; // ── JSON-RPC Helpers ──────────────────────────────────────────────────── function rpcResult(id: number | string | null, result: unknown) { @@ -37,6 +50,31 @@ function rpcError(id: number | string | null, code: number, message: string) { // ── Session Management ────────────────────────────────────────────────── const sessions = new Map(); +const rateLimits = new Map(); +let activeToolCalls = 0; + +function allowRequest(client: string): boolean { + const now = Date.now(); + const entry = rateLimits.get(client); + if (!entry || now - entry.startedAt >= 60_000) { + rateLimits.set(client, { startedAt: now, count: 1 }); + return true; + } + entry.count += 1; + return entry.count <= RATE_LIMIT_PER_MINUTE; +} + +function auditTool(name: string, ok: boolean, durationMs: number): void { + const line = JSON.stringify({ + time: new Date().toISOString(), + event: "tool_call", + tool: name, + risk: getToolRisk(name), + ok, + duration_ms: durationMs, + }) + "\n"; + appendFile(AUDIT_LOG, line, { mode: 0o600 }, () => {}); +} function newSessionId(): string { return crypto.randomUUID(); @@ -124,8 +162,20 @@ async function handleMcpRequest( sessionId: sessionId ?? "", }; } + if (activeToolCalls >= MAX_CONCURRENT_TOOL_CALLS) { + return { + response: rpcResult(id, { + content: [{ type: "text", text: "ERROR: Server busy; retry later" }], + isError: true, + }), + sessionId: sessionId ?? "", + }; + } + activeToolCalls += 1; + const startedAt = Date.now(); try { const text = await tool.handler(args); + auditTool(toolName, true, Date.now() - startedAt); const result: Record = { content: [{ type: "text", text }], }; @@ -146,6 +196,7 @@ async function handleMcpRequest( } return { response: rpcResult(id, result), sessionId: sessionId ?? "" }; } catch (e) { + auditTool(toolName, false, Date.now() - startedAt); return { response: rpcResult(id, { content: [{ type: "text", text: `ERROR: ${String(e)}` }], @@ -153,6 +204,8 @@ async function handleMcpRequest( }), sessionId: sessionId ?? "", }; + } finally { + activeToolCalls -= 1; } } @@ -183,15 +236,21 @@ const server = Bun.serve({ const method = req.method; const sessionHeader = req.headers.get("mcp-session-id"); - // CORS für lokale Nutzung - const corsHeaders: Record = { - "Access-Control-Allow-Origin": "*", + // Browserzugriffe sind standardmäßig deaktiviert. Bei Bedarf kann genau + // ein vertrauenswürdiger Origin per MUA_CORS_ORIGIN freigegeben werden. + const origin = req.headers.get("origin") ?? ""; + const corsHeaders: Record = CORS_ORIGIN && origin === CORS_ORIGIN ? { + "Access-Control-Allow-Origin": CORS_ORIGIN, "Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Mcp-Session-Id", - }; + "Access-Control-Allow-Headers": "Authorization, Content-Type, Mcp-Session-Id", + "Vary": "Origin", + } : {}; // ── OPTIONS (CORS Preflight) ──────────────────────────────────────── if (method === "OPTIONS") { + if (!CORS_ORIGIN || origin !== CORS_ORIGIN) { + return Response.json({ error: "CORS origin not allowed" }, { status: 403 }); + } return new Response(null, { status: 204, headers: corsHeaders }); } @@ -212,6 +271,13 @@ const server = Bun.serve({ // ── Auth-Check für /mcp (POST + DELETE) — Bearer-Token ───────────── if (path === "/mcp" || path === "/") { + const client = server.requestIP(req)?.address ?? "unknown"; + if (!allowRequest(client)) { + return Response.json( + rpcError(null, -32002, "Rate limit exceeded"), + { status: 429, headers: { ...corsHeaders, "Retry-After": "60" } }, + ); + } if (!checkAuth(req)) { return Response.json( { @@ -234,6 +300,13 @@ const server = Bun.serve({ if (path === "/mcp" || path === "/") { // POST: JSON-RPC Request if (method === "POST") { + const contentLength = Number(req.headers.get("content-length") ?? 0); + if (contentLength > MAX_REQUEST_BYTES) { + return Response.json(rpcError(null, -32600, "Request too large"), { + status: 413, + headers: corsHeaders, + }); + } let body: string; try { body = await req.text(); @@ -243,6 +316,12 @@ const server = Bun.serve({ { status: 400, headers: corsHeaders }, ); } + if (new TextEncoder().encode(body).byteLength > MAX_REQUEST_BYTES) { + return Response.json(rpcError(null, -32600, "Request too large"), { + status: 413, + headers: corsHeaders, + }); + } let message: Record; try { @@ -303,6 +382,14 @@ const server = Bun.serve({ // Kein Header-Spoofing, kein File-Permission-Problem. // Wenn der Port belegt ist: Warnung + weiterlaufen (MCP-Server bleibt up). const CONFIG_PORT = Number(process.env["MUA_CONFIG_PORT"] ?? 3013); +const ADMIN_TOKEN_FILE = process.env["MUA_ADMIN_TOKEN_FILE"] ?? "/var/run/mua-admin.token"; +const adminToken = randomBytes(32).toString("hex"); +try { + writeFileSync(ADMIN_TOKEN_FILE, adminToken + "\n", { mode: 0o600 }); + chmodSync(ADMIN_TOKEN_FILE, 0o600); +} catch (e) { + console.error(`[MUA] Config-Token konnte nicht geschrieben werden: ${String(e)}`); +} let configServer: ReturnType | null = null; try { configServer = Bun.serve({ @@ -318,6 +405,10 @@ try { return Response.json({ error: "Not found" }, { status: 404 }); } + if (req.headers.get("x-mua-admin-token") !== adminToken) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + // POST /restart: Service neu starten (für WebGUI-Button) if (path === "/restart" && method === "POST") { const rcScript = process.env["MUA_RC_SCRIPT"] ?? "/etc/rc.d/rc.mua"; @@ -336,9 +427,15 @@ try { // GET: Config lesen if (method === "GET") { return Response.json({ - apiKey: getApiKey(), + apiKeyConfigured: true, + apiKeyMasked: getMaskedApiKey(), enabledTools: getEnabledTools(), - allTools: TOOLS.map((t) => t.name), + allToolsEnabled: getAllToolsEnabled(), + allTools: TOOLS.map((t) => ({ + name: t.name, + description: t.description, + risk: getToolRisk(t.name), + })), }); } @@ -352,21 +449,21 @@ try { } // "generate": neuen API-Key generieren - if (body["generate"] === true) { - setApiKey(randomBytes(32).toString("hex")); - } else if (typeof body["apiKey"] === "string" && (body["apiKey"] as string).length > 0) { - setApiKey(body["apiKey"] as string); - } + let generatedApiKey: string | undefined; + if (body["generate"] === true) generatedApiKey = generateApiKey(); if (Array.isArray(body["enabledTools"])) { + const known = new Set(TOOLS.map((t) => t.name)); const tools = (body["enabledTools"] as unknown[]) - .filter((t): t is string => typeof t === "string"); - setEnabledTools(tools); + .filter((t): t is string => typeof t === "string" && known.has(t)); + setEnabledTools(tools, body["allToolsEnabled"] === true); } return Response.json({ ok: true, - apiKey: getApiKey(), + generatedApiKey, + apiKeyMasked: getMaskedApiKey(), enabledTools: getEnabledTools(), + allToolsEnabled: getAllToolsEnabled(), }); } @@ -428,6 +525,19 @@ const updateCheckInterval = setInterval( ); updateCheckInterval.unref(); +// Abgelaufene Sessions und Rate-Limit-Einträge entfernen, damit lange +// Laufzeiten nicht durch beliebig viele Client-Adressen Speicher ansammeln. +const housekeepingInterval = setInterval(() => { + const now = Date.now(); + for (const [id, session] of sessions) { + if (now - session.lastActivity > 60 * 60 * 1000) sessions.delete(id); + } + for (const [client, entry] of rateLimits) { + if (now - entry.startedAt > 2 * 60 * 1000) rateLimits.delete(client); + } +}, 5 * 60 * 1000); +housekeepingInterval.unref(); + // Graceful shutdown process.on("SIGTERM", () => { console.log("[MUA] SIGTERM received, shutting down"); diff --git a/src/security.test.ts b/src/security.test.ts new file mode 100644 index 0000000..0102ac7 --- /dev/null +++ b/src/security.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from "bun:test"; +import { parseConfig, SAFE_DEFAULT_TOOLS } from "./auth"; +import { sanitizeLogOutput } from "./helpers"; +import { getToolRisk } from "./tools"; + +describe("secure tool configuration", () => { + test("new and incomplete configs use the read-only baseline", () => { + const cfg = parseConfig("MUA_API_KEY=test\n"); + expect(cfg.allToolsEnabled).toBe(false); + expect(cfg.enabledTools).toEqual(SAFE_DEFAULT_TOOLS); + expect(cfg.enabledTools).not.toContain("unraid_system_shell"); + }); + + test("none means no tools instead of all tools", () => { + const cfg = parseConfig("MUA_API_KEY=test\nMUA_ENABLED_TOOLS=none\n"); + expect(cfg.allToolsEnabled).toBe(false); + expect(cfg.enabledTools).toEqual([]); + }); + + test("legacy all remains backwards compatible and explicit", () => { + const cfg = parseConfig("MUA_API_KEY=test\nMUA_ENABLED_TOOLS=all\n"); + expect(cfg.allToolsEnabled).toBe(true); + expect(cfg.enabledTools).toEqual([]); + }); +}); + +describe("secret handling", () => { + test("redacts common key-value and JSON secrets", () => { + const output = sanitizeLogOutput( + 'API_KEY=very-secret password:also-secret {"token":"third-secret"}', + ); + expect(output).not.toContain("very-secret"); + expect(output).not.toContain("also-secret"); + expect(output).not.toContain("third-secret"); + expect(output).toContain("[REDACTED]"); + }); +}); + +describe("risk classification", () => { + test("classifies root shell and container changes as critical", () => { + expect(getToolRisk("unraid_system_shell")).toBe("critical"); + expect(getToolRisk("unraid_docker_modify")).toBe("critical"); + expect(getToolRisk("unraid_docker_restart")).toBe("write"); + expect(getToolRisk("unraid_network_lan_probe")).toBe("active"); + expect(getToolRisk("unraid_docker_list")).toBe("read"); + }); +}); diff --git a/src/tools.ts b/src/tools.ts index 62256dd..0bd14ce 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -29,6 +29,32 @@ export interface ToolDef { handler: (args: Record) => Promise; } +export type ToolRisk = "read" | "active" | "write" | "critical"; + +const CRITICAL_TOOLS = new Set([ + "unraid_docker_create", + "unraid_docker_modify", + "unraid_docker_update", + "unraid_docker_rebuild", + "unraid_system_shell", +]); +const WRITE_TOOLS = new Set([ + "unraid_docker_start", + "unraid_docker_stop", + "unraid_docker_restart", +]); +const ACTIVE_TOOLS = new Set([ + "unraid_network_audit_tcp", + "unraid_network_lan_probe", +]); + +export function getToolRisk(name: string): ToolRisk { + if (CRITICAL_TOOLS.has(name)) return "critical"; + if (WRITE_TOOLS.has(name)) return "write"; + if (ACTIVE_TOOLS.has(name)) return "active"; + return "read"; +} + const str = (desc: string) => ({ type: "string", description: desc }); const int = (desc: string) => ({ type: "integer", description: desc }); const num = (desc: string) => ({ type: "number", description: desc }); @@ -47,7 +73,7 @@ export const TOOLS: ToolDef[] = [ { name: "unraid_docker_inspect", description: - "Inspect a single Docker container in detail (state, image, ports, env, mounts).", + "Inspect a single Docker container in detail (state, image, ports, redacted environment variable names, mounts). Secret values are never returned.", inputSchema: { type: "object", properties: { container: str("Container name or ID") }, @@ -306,7 +332,7 @@ export const TOOLS: ToolDef[] = [ { name: "unraid_system_shell", description: - "Execute a shell command on the Unraid host (as root, via /bin/sh -c) and return exit code, stdout, and stderr. Use for direct terminal access: file inspection, system commands, package info, log reading, etc. Commands run with a timeout and output is size-limited.", + "CRITICAL: Execute an unrestricted shell command on the Unraid host as root. Keep this tool disabled unless explicitly needed for a supervised maintenance session.", inputSchema: { type: "object", properties: {