release r008 add safe read-only shell
This commit is contained in:
@@ -7,7 +7,7 @@ Kompiliertes Bun-Binary (TypeScript), läuft als SysVinit-Service auf Unraid.
|
|||||||
- **Health-Check:** `http://<unraid-ip>:3002/health` (offen, ohne Auth)
|
- **Health-Check:** `http://<unraid-ip>:3002/health` (offen, ohne Auth)
|
||||||
- **Config-Server:** `http://127.0.0.1:3013/config` (localhost + separater Admin-Token)
|
- **Config-Server:** `http://127.0.0.1:3013/config` (localhost + separater Admin-Token)
|
||||||
- **Auth:** API-Key (Bearer-Token) für `/mcp`
|
- **Auth:** API-Key (Bearer-Token) für `/mcp`
|
||||||
- **Tools:** 22 (Docker: 14, Netzwerk: 6, System: 2)
|
- **Tools:** 23 (Docker: 14, Netzwerk: 6, System: 3)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -43,6 +43,10 @@ Die WebGUI schützt schreibende Formulare außerdem mit einem CSRF-Token.
|
|||||||
|
|
||||||
- Neuinstallationen starten im Profil **Nur Lesen**. Container-Steuerung,
|
- Neuinstallationen starten im Profil **Nur Lesen**. Container-Steuerung,
|
||||||
aktive Netzwerktests, Container-Umbauten und die Root-Shell sind aus.
|
aktive Netzwerktests, Container-Umbauten und die Root-Shell sind aus.
|
||||||
|
- Für Diagnosen steht `unraid_system_shell_readonly` bereit. Es startet nur
|
||||||
|
fest freigegebene Leseprogramme als direkte Argumentliste, niemals über
|
||||||
|
`/bin/sh`; Verkettungen, Pipes, Umleitungen und schreibende Optionen werden
|
||||||
|
dadurch verhindert. Die freie Root-Shell bleibt separat und kritisch.
|
||||||
- `none` bedeutet tatsächlich **keine Tools aktiv**; `all` ist ein expliziter
|
- `none` bedeutet tatsächlich **keine Tools aktiv**; `all` ist ein expliziter
|
||||||
Vollzugriff und wird in der GUI deutlich gewarnt.
|
Vollzugriff und wird in der GUI deutlich gewarnt.
|
||||||
- Container-Umgebungswerte werden nie ausgegeben, nur ihre Variablennamen.
|
- Container-Umgebungswerte werden nie ausgegeben, nur ihre Variablennamen.
|
||||||
@@ -68,10 +72,10 @@ Oder manuell:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# .txz von Gitea laden
|
# .txz von Gitea laden
|
||||||
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
|
curl -O http://192.168.1.2:4000/michael/MUA-Mikes-Unraid-Agent/raw/branch/main/dist/mua-2026.08.21.r008-x86_64-1.txz
|
||||||
|
|
||||||
# Installieren
|
# Installieren
|
||||||
upgradepkg --install-new mua-2026.08.21.r007-x86_64-1.txz
|
upgradepkg --install-new mua-2026.08.21.r008-x86_64-1.txz
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Service starten
|
### 2. Service starten
|
||||||
@@ -86,7 +90,7 @@ Der Service startet automatisch bei jedem Boot (SysVinit).
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl http://192.168.1.2:3002/health
|
curl http://192.168.1.2:3002/health
|
||||||
# → {"status":"ok","version":"2026.08.21.r007","auth":"required"}
|
# → {"status":"ok","version":"2026.08.21.r008","auth":"required"}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -206,7 +210,7 @@ Zusätzlich kann jedes Werkzeug einzeln nach Risikostufe freigegeben werden:
|
|||||||
|--------|-------|
|
|--------|-------|
|
||||||
| **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` |
|
| **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` |
|
| **Netzwerk (6)** | `unraid_network_inventory`, `unraid_network_list`, `unraid_network_inspect`, `unraid_network_host_state`, `unraid_network_audit_tcp`, `unraid_network_lan_probe` |
|
||||||
| **System (2)** | `unraid_system_connection_test`, `unraid_system_shell` |
|
| **System (3)** | `unraid_system_connection_test`, `unraid_system_shell_readonly`, `unraid_system_shell` |
|
||||||
|
|
||||||
Deaktivierte Tools werden vom MCP-Server gefiltert — sie erscheinen nicht in
|
Deaktivierte Tools werden vom MCP-Server gefiltert — sie erscheinen nicht in
|
||||||
`tools/list` und können nicht aufgerufen werden (→ `ERROR: Tool disabled`).
|
`tools/list` und können nicht aufgerufen werden (→ `ERROR: Tool disabled`).
|
||||||
|
|||||||
BIN
Binary file not shown.
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mua",
|
"name": "mua",
|
||||||
"version": "2026.08.21.r007",
|
"version": "2026.08.21.r008",
|
||||||
"description": "Mikes Unraid Agent - MCP over HTTP (Streamable HTTP) for Unraid",
|
"description": "Mikes Unraid Agent - MCP over HTTP (Streamable HTTP) for Unraid",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
|
|||||||
+9
-4
@@ -2,13 +2,13 @@
|
|||||||
<!DOCTYPE PLUGIN [
|
<!DOCTYPE PLUGIN [
|
||||||
<!ENTITY name "mua">
|
<!ENTITY name "mua">
|
||||||
<!ENTITY author "Michael">
|
<!ENTITY author "Michael">
|
||||||
<!ENTITY version "2026.08.21.r007">
|
<!ENTITY version "2026.08.21.r008">
|
||||||
<!ENTITY launch "Settings/mua">
|
<!ENTITY launch "Settings/mua">
|
||||||
<!ENTITY pluginURL "http://192.168.1.2:4000/michael/MUA-Mikes-Unraid-Agent/raw/branch/main/plugin/mua.plg">
|
<!ENTITY pluginURL "http://192.168.1.2:4000/michael/MUA-Mikes-Unraid-Agent/raw/branch/main/plugin/mua.plg">
|
||||||
<!ENTITY pluginLOC "/boot/config/plugins/&name;">
|
<!ENTITY pluginLOC "/boot/config/plugins/&name;">
|
||||||
<!ENTITY emhttpLOC "/usr/local/emhttp/plugins/&name;">
|
<!ENTITY emhttpLOC "/usr/local/emhttp/plugins/&name;">
|
||||||
<!ENTITY txzURL "http://192.168.1.2:4000/michael/MUA-Mikes-Unraid-Agent/raw/branch/main/dist/mua-2026.08.21.r007-x86_64-1.txz">
|
<!ENTITY txzURL "http://192.168.1.2:4000/michael/MUA-Mikes-Unraid-Agent/raw/branch/main/dist/mua-2026.08.21.r008-x86_64-1.txz">
|
||||||
<!ENTITY txzSHA256 "04dd05ca142ccaaf3d20f81a855714934e2bef8c0425484290beffd0f2b3f339">
|
<!ENTITY txzSHA256 "c6cdef23df5453fbb28a4c2c9e90d2e03f38ef396022ba2fda2823895b6c2074">
|
||||||
]>
|
]>
|
||||||
|
|
||||||
<PLUGIN name="&name;"
|
<PLUGIN name="&name;"
|
||||||
@@ -23,6 +23,11 @@
|
|||||||
>
|
>
|
||||||
|
|
||||||
<CHANGES>
|
<CHANGES>
|
||||||
|
### 2026.08.21.r008
|
||||||
|
- Neue getrennte Nur-Lese-Shell: unraid_system_shell_readonly mit Programm-Allowlist und direkter argv-Ausführung ohne /bin/sh.
|
||||||
|
- Schreibende Unterbefehle und Optionen für ip, find, ss, dmesg, date, mount und hostname werden serverseitig abgelehnt.
|
||||||
|
- Die uneingeschränkte Root-Shell bleibt als separates kritisches Werkzeug standardmäßig deaktiviert.
|
||||||
|
- GUI-Fix: MUA-eigene CSS-Klassennamen verhindern die Kollision mit Unraids globaler notice-Klasse; die blaue Karte wird wieder korrekt dargestellt.
|
||||||
### 2026.08.21.r007
|
### 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).
|
- 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.
|
- Sicherheitsprofile: Nur Lesen (Standard), Betrieb + Diagnose, Alles sperren und expliziter Vollzugriff.
|
||||||
@@ -91,7 +96,7 @@ Das .txz enthält:
|
|||||||
install/doinst.sh (läuft nach Installation)
|
install/doinst.sh (läuft nach Installation)
|
||||||
===========================================
|
===========================================
|
||||||
-->
|
-->
|
||||||
<FILE Name="/boot/config/plugins/&name;/mua-2026.08.21.r007-x86_64-1.txz" Run="upgradepkg --install-new" Mode="755" Min="7.0.0">
|
<FILE Name="/boot/config/plugins/&name;/mua-2026.08.21.r008-x86_64-1.txz" Run="upgradepkg --install-new" Mode="755" Min="7.0.0">
|
||||||
<URL>&txzURL;</URL>
|
<URL>&txzURL;</URL>
|
||||||
<SHA256>&txzSHA256;</SHA256>
|
<SHA256>&txzSHA256;</SHA256>
|
||||||
</FILE>
|
</FILE>
|
||||||
|
|||||||
+7
-6
@@ -70,6 +70,7 @@ $readonly = [
|
|||||||
'unraid_docker_analyze_logs', 'unraid_docker_processes', 'unraid_docker_stats',
|
'unraid_docker_analyze_logs', 'unraid_docker_processes', 'unraid_docker_stats',
|
||||||
'unraid_docker_info', 'unraid_network_inventory', 'unraid_network_list',
|
'unraid_docker_info', 'unraid_network_inventory', 'unraid_network_list',
|
||||||
'unraid_network_inspect', 'unraid_network_host_state', 'unraid_system_connection_test',
|
'unraid_network_inspect', 'unraid_network_host_state', 'unraid_system_connection_test',
|
||||||
|
'unraid_system_shell_readonly',
|
||||||
];
|
];
|
||||||
$operator = array_merge($readonly, [
|
$operator = array_merge($readonly, [
|
||||||
'unraid_network_audit_tcp', 'unraid_network_lan_probe',
|
'unraid_network_audit_tcp', 'unraid_network_lan_probe',
|
||||||
@@ -130,18 +131,18 @@ foreach ($all_tools as $tool) {
|
|||||||
$risk_groups[$risk][] = $tool;
|
$risk_groups[$risk][] = $tool;
|
||||||
}
|
}
|
||||||
$risk_labels = [
|
$risk_labels = [
|
||||||
'read' => ['Nur Lesen', 'Liest Status und Diagnoseinformationen.', 'safe'],
|
'read' => ['Nur Lesen', 'Liest Status und Diagnoseinformationen.', 'mua-risk-safe'],
|
||||||
'active' => ['Aktive Prüfungen', 'Baut gezielt Netzwerkverbindungen für Tests auf.', 'notice'],
|
'active' => ['Aktive Prüfungen', 'Baut gezielt Netzwerkverbindungen für Tests auf.', 'mua-risk-notice'],
|
||||||
'write' => ['Betrieb steuern', 'Startet, stoppt oder startet Container neu.', 'warn'],
|
'write' => ['Betrieb steuern', 'Startet, stoppt oder startet Container neu.', 'mua-risk-warn'],
|
||||||
'critical' => ['Kritischer Zugriff', 'Verändert Container oder führt uneingeschränkte Root-Befehle aus.', 'danger'],
|
'critical' => ['Kritischer Zugriff', 'Verändert Container oder führt uneingeschränkte Root-Befehle aus.', 'mua-risk-danger'],
|
||||||
];
|
];
|
||||||
$active_count = $all_tools_enabled ? count($all_tools) : count($enabled_tools);
|
$active_count = $all_tools_enabled ? count($all_tools) : count($enabled_tools);
|
||||||
?>
|
?>
|
||||||
<style>
|
<style>
|
||||||
.mua-wrap{max-width:1280px}.mua-hero{display:flex;justify-content:space-between;align-items:flex-start;gap:18px;margin:12px 0 20px}.mua-title h2{margin:0 0 5px;font-size:1.55rem}.mua-muted{color:#777}.mua-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(285px,1fr));gap:14px;margin:14px 0}.mua-card{border:1px solid #d8d8d8;border-radius:9px;padding:16px;background:rgba(255,255,255,.03)}.mua-card h3{margin:0 0 8px}.mua-status{padding:11px 14px;border-radius:7px;margin:10px 0;border:1px solid}.mua-status.ok,.safe{border-color:#43a047;background:rgba(67,160,71,.11)}.mua-status.err,.danger{border-color:#e53935;background:rgba(229,57,53,.11)}.mua-status.warn,.warn{border-color:#fb8c00;background:rgba(251,140,0,.12)}.notice{border-color:#1e88e5;background:rgba(30,136,229,.10)}.mua-pill{display:inline-block;padding:3px 9px;border-radius:20px;font-size:.82em;font-weight:600;border:1px solid currentColor}.mua-endpoint{font-size:1.05em;word-break:break-all}.mua-key{display:block;padding:12px;border:1px solid #43a047;border-radius:6px;word-break:break-all;font-size:1.04em;background:rgba(67,160,71,.08)}.mua-actions{display:flex;flex-wrap:wrap;gap:8px;margin:10px 0}.mua-btn{padding:8px 14px;border-radius:5px;border:1px solid #aaa;cursor:pointer}.mua-btn-primary{background:#168bd2;color:#fff;border-color:#168bd2}.mua-btn-danger{background:#c62828;color:#fff;border-color:#c62828}.mua-tool{display:grid;grid-template-columns:22px 1fr;gap:8px;padding:9px 0;border-top:1px solid rgba(128,128,128,.2)}.mua-tool:first-of-type{border-top:0}.mua-tool code{font-size:.86em}.mua-tool small{display:block;color:#777;margin-top:3px;line-height:1.35}.mua-section{margin-top:24px}.mua-summary{font-size:1.08em}.mua-preset{margin:0}.mua-details td:first-child{width:190px;font-weight:600}@media(max-width:700px){.mua-hero{display:block}.mua-actions{display:grid}.mua-btn{width:100%}}
|
.mua-wrap{max-width:1280px}.mua-hero{display:flex;justify-content:space-between;align-items:flex-start;gap:18px;margin:12px 0 20px}.mua-title h2{margin:0 0 5px;font-size:1.55rem}.mua-muted{color:#777}.mua-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(285px,1fr));gap:14px;margin:14px 0;align-items:start}.mua-card{border:1px solid #d8d8d8;border-radius:9px;padding:16px;background:rgba(255,255,255,.03)}.mua-card h3{margin:0 0 8px}.mua-status{padding:11px 14px;border-radius:7px;margin:10px 0;border:1px solid}.mua-status.ok,.mua-risk-safe{border-color:#43a047;background:rgba(67,160,71,.11)}.mua-status.err,.mua-risk-danger{border-color:#e53935;background:rgba(229,57,53,.11)}.mua-status.warn,.mua-risk-warn{border-color:#fb8c00;background:rgba(251,140,0,.12)}.mua-risk-notice{border-color:#1e88e5;background:rgba(30,136,229,.10)}.mua-pill{display:inline-block;padding:3px 9px;border-radius:20px;font-size:.82em;font-weight:600;border:1px solid currentColor}.mua-endpoint{font-size:1.05em;word-break:break-all}.mua-key{display:block;padding:12px;border:1px solid #43a047;border-radius:6px;word-break:break-all;font-size:1.04em;background:rgba(67,160,71,.08)}.mua-actions{display:flex;flex-wrap:wrap;gap:8px;margin:10px 0}.mua-btn{padding:8px 14px;border-radius:5px;border:1px solid #aaa;cursor:pointer}.mua-btn-primary{background:#168bd2;color:#fff;border-color:#168bd2}.mua-btn-danger{background:#c62828;color:#fff;border-color:#c62828}.mua-tool{display:grid;grid-template-columns:22px minmax(0,1fr);gap:8px;padding:9px 0;border-top:1px solid rgba(128,128,128,.2);align-items:start}.mua-tool:first-of-type{border-top:0}.mua-tool code{font-size:.86em;overflow-wrap:anywhere}.mua-tool small{display:block;color:#777;margin-top:3px;line-height:1.35}.mua-section{margin-top:24px}.mua-summary{font-size:1.08em}.mua-preset{margin:0}.mua-details td:first-child{width:190px;font-weight:600}@media(max-width:700px){.mua-hero{display:block}.mua-actions{display:grid}.mua-btn{width:100%}}
|
||||||
</style>
|
</style>
|
||||||
<div class="mua-wrap">
|
<div class="mua-wrap">
|
||||||
<div class="mua-hero"><div class="mua-title"><h2>MUA · Mikes Unraid Agent</h2><div class="mua-muted">Sicherer MCP-Zugriff auf Docker, Netzwerk und Unraid-Systemfunktionen</div></div><div><span class="mua-pill <?= $running && $health_ok ? 'safe' : 'danger' ?>"><?= $running && $health_ok ? '● Dienst bereit' : '● Dienst gestört' ?></span></div></div>
|
<div class="mua-hero"><div class="mua-title"><h2>MUA · Mikes Unraid Agent</h2><div class="mua-muted">Sicherer MCP-Zugriff auf Docker, Netzwerk und Unraid-Systemfunktionen</div></div><div><span class="mua-pill <?= $running && $health_ok ? 'mua-risk-safe' : 'mua-risk-danger' ?>"><?= $running && $health_ok ? '● Dienst bereit' : '● Dienst gestört' ?></span></div></div>
|
||||||
|
|
||||||
<?php if ($flash_msg): ?><div class="mua-status <?= htmlspecialchars($flash_type) ?>"><?= htmlspecialchars($flash_msg) ?></div><?php endif; ?>
|
<?php if ($flash_msg): ?><div class="mua-status <?= htmlspecialchars($flash_type) ?>"><?= htmlspecialchars($flash_msg) ?></div><?php endif; ?>
|
||||||
<?php if ($new_api_key): ?><div class="mua-status ok"><strong>Neuer API-Key – einmalige Anzeige</strong><code class="mua-key" id="mua-new-key"><?= htmlspecialchars($new_api_key) ?></code><button type="button" class="mua-btn" onclick="navigator.clipboard.writeText(document.getElementById('mua-new-key').textContent)">In Zwischenablage kopieren</button></div><?php endif; ?>
|
<?php if ($new_api_key): ?><div class="mua-status ok"><strong>Neuer API-Key – einmalige Anzeige</strong><code class="mua-key" id="mua-new-key"><?= htmlspecialchars($new_api_key) ?></code><button type="button" class="mua-btn" onclick="navigator.clipboard.writeText(document.getElementById('mua-new-key').textContent)">In Zwischenablage kopieren</button></div><?php endif; ?>
|
||||||
|
|||||||
+1
-1
@@ -100,7 +100,7 @@ cat > "${BUILD_DIR}/install/slack-desc" << DESCEOF
|
|||||||
|
|
|
|
||||||
|${PKG_NAME} - Mikes Unraid Agent
|
|${PKG_NAME} - Mikes Unraid Agent
|
||||||
|MCP over HTTP (Streamable HTTP) Server für Unraid.
|
|MCP over HTTP (Streamable HTTP) Server für Unraid.
|
||||||
|22 Tools: Docker (14), Netzwerk (6), System (2).
|
|23 Tools: Docker (14), Netzwerk (6), System (3).
|
||||||
|Port: 3002, Endpunkt: /mcp
|
|Port: 3002, Endpunkt: /mcp
|
||||||
|
|
|
|
||||||
|Runtime: TypeScript (Bun Runtime, kompiliertes Binary)
|
|Runtime: TypeScript (Bun Runtime, kompiliertes Binary)
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export const SAFE_DEFAULT_TOOLS = [
|
|||||||
"unraid_network_inspect",
|
"unraid_network_inspect",
|
||||||
"unraid_network_host_state",
|
"unraid_network_host_state",
|
||||||
"unraid_system_connection_test",
|
"unraid_system_connection_test",
|
||||||
|
"unraid_system_shell_readonly",
|
||||||
];
|
];
|
||||||
|
|
||||||
// ── Config laden ────────────────────────────────────────────────────────
|
// ── Config laden ────────────────────────────────────────────────────────
|
||||||
|
|||||||
+114
-1
@@ -13,7 +13,7 @@ import { createConnection, type Socket } from "net";
|
|||||||
|
|
||||||
// ── Konstanten ──────────────────────────────────────────────────────────
|
// ── Konstanten ──────────────────────────────────────────────────────────
|
||||||
export const MUA_SERVER_NAME = "mua";
|
export const MUA_SERVER_NAME = "mua";
|
||||||
export const MUA_VERSION = "2026.08.21.r007";
|
export const MUA_VERSION = "2026.08.21.r008";
|
||||||
export const MUA_PROTOCOL_VERSION = "2025-03-26";
|
export const MUA_PROTOCOL_VERSION = "2025-03-26";
|
||||||
export const PHP_HELPER = "/usr/local/bin/unraid-docker-mcp-helper.php";
|
export const PHP_HELPER = "/usr/local/bin/unraid-docker-mcp-helper.php";
|
||||||
|
|
||||||
@@ -81,6 +81,119 @@ export async function runShell(cmd: string, timeoutSec = 60): Promise<string> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const READ_ONLY_PROGRAMS = new Set([
|
||||||
|
"cat", "date", "df", "dmesg", "du", "file", "find", "free", "grep",
|
||||||
|
"head", "hostname", "id", "ip", "lsof", "ls", "lsblk", "lspci", "mount",
|
||||||
|
"ps", "readlink", "realpath", "sha256sum", "ss", "stat", "tail", "uname",
|
||||||
|
"uptime", "wc", "whoami",
|
||||||
|
]);
|
||||||
|
|
||||||
|
async function readStreamLimited(
|
||||||
|
stream: ReadableStream<Uint8Array>,
|
||||||
|
maxBytes: number,
|
||||||
|
): Promise<{ text: string; truncated: boolean }> {
|
||||||
|
const reader = stream.getReader();
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
let kept = 0;
|
||||||
|
let truncated = false;
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
const available = Math.max(0, maxBytes - kept);
|
||||||
|
if (kept < maxBytes) {
|
||||||
|
const slice = value.subarray(0, available);
|
||||||
|
if (slice.length > 0) chunks.push(slice);
|
||||||
|
kept += slice.length;
|
||||||
|
}
|
||||||
|
if (value.length > available) truncated = true;
|
||||||
|
}
|
||||||
|
const combined = new Uint8Array(chunks.reduce((n, c) => n + c.length, 0));
|
||||||
|
let offset = 0;
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
combined.set(chunk, offset);
|
||||||
|
offset += chunk.length;
|
||||||
|
}
|
||||||
|
return { text: new TextDecoder().decode(combined), truncated };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Führt ausschließlich freigegebene Leseprogramme direkt als argv aus.
|
||||||
|
* Kein /bin/sh, keine Pipes, Umleitungen, Substitutionen oder Verkettungen.
|
||||||
|
*/
|
||||||
|
export async function runReadOnlyCommand(
|
||||||
|
program: string,
|
||||||
|
args: string[],
|
||||||
|
timeoutSec = 30,
|
||||||
|
): Promise<string> {
|
||||||
|
if (!READ_ONLY_PROGRAMS.has(program)) {
|
||||||
|
throw new Error(`Program is not allowed in read-only mode: ${program}`);
|
||||||
|
}
|
||||||
|
if (args.length > 64 || args.some((arg) => typeof arg !== "string" || arg.length > 4096)) {
|
||||||
|
throw new Error("Invalid or excessive arguments");
|
||||||
|
}
|
||||||
|
|
||||||
|
const lowered = args.map((arg) => arg.toLowerCase());
|
||||||
|
const reject = (message: string) => { throw new Error(message); };
|
||||||
|
if (["hostname", "whoami", "uptime"].includes(program) && args.length > 0) {
|
||||||
|
reject(`${program} does not accept arguments in read-only mode`);
|
||||||
|
}
|
||||||
|
if (program === "date") {
|
||||||
|
const safeDateFlags = new Set(["-u", "--utc", "-r", "--reference", "--rfc-email", "-d", "--date"]);
|
||||||
|
for (let i = 0; i < args.length; i++) {
|
||||||
|
const arg = lowered[i];
|
||||||
|
if (i > 0 && ["-d", "--date", "-r", "--reference"].includes(lowered[i - 1])) continue;
|
||||||
|
if (arg.startsWith("+") || safeDateFlags.has(arg) || arg.startsWith("--date=") ||
|
||||||
|
arg.startsWith("--iso-8601") || arg.startsWith("--rfc-3339")) continue;
|
||||||
|
reject("Only date display and parsing options are allowed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (program === "dmesg" && args.some((a) =>
|
||||||
|
["-C", "-D", "-E", "-n"].includes(a) ||
|
||||||
|
["--clear", "--read-clear", "--console-off", "--console-on", "--console-level"].includes(a.toLowerCase())
|
||||||
|
)) {
|
||||||
|
reject("Changing or clearing the kernel log is not allowed");
|
||||||
|
}
|
||||||
|
if (program === "ss" && lowered.some((a) => a === "-k" || a === "--kill")) {
|
||||||
|
reject("Killing sockets is not allowed");
|
||||||
|
}
|
||||||
|
if (program === "mount" && args.length > 0) {
|
||||||
|
reject("mount is display-only and accepts no arguments in read-only mode");
|
||||||
|
}
|
||||||
|
if (program === "ip") {
|
||||||
|
const safeObjects = new Set(["address", "addr", "route", "link", "neigh", "neighbor"]);
|
||||||
|
const mutating = new Set(["add", "append", "change", "delete", "del", "flush", "replace", "set"]);
|
||||||
|
if (args.length === 0 || !safeObjects.has(lowered[0]) || lowered.some((a) => mutating.has(a))) {
|
||||||
|
reject("Only read-only ip objects and show/list operations are allowed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (program === "find") {
|
||||||
|
const mutatingFind = ["-delete", "-exec", "-execdir", "-ok", "-okdir", "-fprint", "-fprintf", "-fls"];
|
||||||
|
if (lowered.some((a) => mutatingFind.some((blocked) => a === blocked || a.startsWith(blocked)))) {
|
||||||
|
reject("Mutating find actions are not allowed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const proc = spawn([program, ...args], { stdout: "pipe", stderr: "pipe", cwd: "/" });
|
||||||
|
const timeout = setTimeout(() => proc.kill(), timeoutSec * 1000);
|
||||||
|
const [stdout, stderr, code] = await Promise.all([
|
||||||
|
readStreamLimited(proc.stdout, 100_000),
|
||||||
|
readStreamLimited(proc.stderr, 20_000),
|
||||||
|
proc.exited,
|
||||||
|
]);
|
||||||
|
clearTimeout(timeout);
|
||||||
|
return JSON.stringify({
|
||||||
|
exit_code: code,
|
||||||
|
stdout: sanitizeLogOutput(stdout.text.trim(), 100_000),
|
||||||
|
stderr: sanitizeLogOutput(stderr.text.trim(), 20_000),
|
||||||
|
truncated: stdout.truncated || stderr.truncated,
|
||||||
|
mode: "read-only",
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`read-only command failed: ${String(e)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Docker-Befehl ausführen (kompakt).
|
* Docker-Befehl ausführen (kompakt).
|
||||||
*/
|
*/
|
||||||
|
|||||||
+17
-1
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { parseConfig, SAFE_DEFAULT_TOOLS } from "./auth";
|
import { parseConfig, SAFE_DEFAULT_TOOLS } from "./auth";
|
||||||
import { sanitizeLogOutput } from "./helpers";
|
import { runReadOnlyCommand, sanitizeLogOutput } from "./helpers";
|
||||||
import { getToolRisk } from "./tools";
|
import { getToolRisk } from "./tools";
|
||||||
|
|
||||||
describe("secure tool configuration", () => {
|
describe("secure tool configuration", () => {
|
||||||
@@ -43,5 +43,21 @@ describe("risk classification", () => {
|
|||||||
expect(getToolRisk("unraid_docker_restart")).toBe("write");
|
expect(getToolRisk("unraid_docker_restart")).toBe("write");
|
||||||
expect(getToolRisk("unraid_network_lan_probe")).toBe("active");
|
expect(getToolRisk("unraid_network_lan_probe")).toBe("active");
|
||||||
expect(getToolRisk("unraid_docker_list")).toBe("read");
|
expect(getToolRisk("unraid_docker_list")).toBe("read");
|
||||||
|
expect(getToolRisk("unraid_system_shell_readonly")).toBe("read");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("read-only shell", () => {
|
||||||
|
test("executes an allowlisted program without a shell", async () => {
|
||||||
|
const result = JSON.parse(await runReadOnlyCommand("ls", ["-ld", "/"], 5));
|
||||||
|
expect(result.exit_code).toBe(0);
|
||||||
|
expect(result.mode).toBe("read-only");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects arbitrary programs and mutating subcommands", async () => {
|
||||||
|
await expect(runReadOnlyCommand("sh", ["-c", "id"], 5)).rejects.toThrow();
|
||||||
|
await expect(runReadOnlyCommand("ip", ["link", "set", "lo", "down"], 5)).rejects.toThrow();
|
||||||
|
await expect(runReadOnlyCommand("find", ["/tmp", "-delete"], 5)).rejects.toThrow();
|
||||||
|
await expect(runReadOnlyCommand("ss", ["-K", "dst", "127.0.0.1"], 5)).rejects.toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+44
-2
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* MUA — Mikes Unraid Agent
|
* MUA — Mikes Unraid Agent
|
||||||
* tools.ts — MCP Tool-Definitionen (22 Tools)
|
* tools.ts — MCP Tool-Definitionen (23 Tools)
|
||||||
*
|
*
|
||||||
* Portiert von mcp/tools.php. Schema: unraid_<kategorie>_<aktion>
|
* Portiert von mcp/tools.php. Schema: unraid_<kategorie>_<aktion>
|
||||||
* Kategorien: docker (14), network (6), system (2).
|
* Kategorien: docker (14), network (6), system (2).
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
connectionTest,
|
connectionTest,
|
||||||
validateName,
|
validateName,
|
||||||
runShell,
|
runShell,
|
||||||
|
runReadOnlyCommand,
|
||||||
} from "./helpers";
|
} from "./helpers";
|
||||||
|
|
||||||
export interface ToolDef {
|
export interface ToolDef {
|
||||||
@@ -321,7 +322,7 @@ export const TOOLS: ToolDef[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── System (2) ────────────────────────────────────────────────────────
|
// ── System (3) ────────────────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
name: "unraid_system_connection_test",
|
name: "unraid_system_connection_test",
|
||||||
description:
|
description:
|
||||||
@@ -329,6 +330,47 @@ export const TOOLS: ToolDef[] = [
|
|||||||
inputSchema: empty,
|
inputSchema: empty,
|
||||||
handler: () => connectionTest(),
|
handler: () => connectionTest(),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "unraid_system_shell_readonly",
|
||||||
|
description:
|
||||||
|
"Run a strictly allowlisted read-only command without a shell interpreter. Supports diagnostics such as ls, tail, head, cat, grep, stat, find, ps, df, du, ss and read-only ip show/list operations. Pipes, redirects, command chaining and mutating options are impossible or rejected.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
program: {
|
||||||
|
type: "string",
|
||||||
|
enum: [
|
||||||
|
"cat", "date", "df", "dmesg", "du", "file", "find", "free", "grep",
|
||||||
|
"head", "hostname", "id", "ip", "lsof", "ls", "lsblk", "lspci", "mount",
|
||||||
|
"ps", "readlink", "realpath", "sha256sum", "ss", "stat", "tail", "uname",
|
||||||
|
"uptime", "wc", "whoami",
|
||||||
|
],
|
||||||
|
description: "Allowlisted read-only program",
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string", maxLength: 4096 },
|
||||||
|
maxItems: 64,
|
||||||
|
description: "Argument vector; passed directly without /bin/sh",
|
||||||
|
},
|
||||||
|
timeout_seconds: int("Timeout in seconds (1-120, default 30)"),
|
||||||
|
},
|
||||||
|
required: ["program"],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
handler: (a) => {
|
||||||
|
const program = (a["program"] as string) ?? "";
|
||||||
|
const rawArgs = a["args"] ?? [];
|
||||||
|
if (!Array.isArray(rawArgs) || rawArgs.some((arg) => typeof arg !== "string")) {
|
||||||
|
throw new Error("args must be an array of strings");
|
||||||
|
}
|
||||||
|
const timeout = Number(a["timeout_seconds"] ?? 30);
|
||||||
|
if (timeout < 1 || timeout > 120) {
|
||||||
|
throw new Error("timeout_seconds must be between 1 and 120");
|
||||||
|
}
|
||||||
|
return runReadOnlyCommand(program, rawArgs as string[], timeout);
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "unraid_system_shell",
|
name: "unraid_system_shell",
|
||||||
description:
|
description:
|
||||||
|
|||||||
Reference in New Issue
Block a user