feat: add bounded file inventory for media audits

This commit is contained in:
Mikei386 committed 2026-08-24 11:49:12 +02:00
1 parent d90874ed18
commit 4426969d63
12 files changed
+163 -12

No files matched your search

+4 -4
View File
@@ -76,10 +76,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.24.r019-x86_64-1.txz
curl -O http://192.168.1.2:4000/michael/MUA-Mikes-Unraid-Agent/raw/branch/main/dist/mua-2026.08.24.r020-x86_64-1.txz
# Installieren
upgradepkg --install-new mua-2026.08.24.r019-x86_64-1.txz
upgradepkg --install-new mua-2026.08.24.r020-x86_64-1.txz
```
### 2. Service starten
@@ -94,7 +94,7 @@ Der Service startet automatisch bei jedem Boot (SysVinit).
```bash
curl http://192.168.1.2:3002/health
# → {"status":"ok","version":"2026.08.24.r019","auth":"required"}
# → {"status":"ok","version":"2026.08.24.r020","auth":"required"}
```
---
@@ -215,7 +215,7 @@ Zusätzlich kann jedes Werkzeug einzeln nach Risikostufe freigegeben werden:
| **Docker (16)** | `unraid_docker_list`, `unraid_docker_inspect`, `unraid_docker_logs`, `unraid_docker_analyze_logs`, `unraid_docker_processes`, `unraid_docker_stats`, `unraid_docker_info`, `unraid_docker_update_status`, `unraid_docker_start`, `unraid_docker_stop`, `unraid_docker_restart`, `unraid_docker_create`, `unraid_docker_modify`, `unraid_docker_update`, `unraid_docker_update_verified_batch`, `unraid_docker_rebuild` |
| **Community Applications (3)** | `unraid_ca_search`, `unraid_ca_install_preview`, `unraid_ca_install` |
| **Netzwerk (6)** | `unraid_network_inventory`, `unraid_network_list`, `unraid_network_inspect`, `unraid_network_host_state`, `unraid_network_audit_tcp`, `unraid_network_lan_probe` |
| **System (9)** | `unraid_system_health`, `unraid_storage_status`, `unraid_disk_health`, `unraid_notifications_list`, `unraid_shares_list`, `unraid_share_inspect`, `unraid_system_connection_test`, `unraid_system_shell_readonly`, `unraid_system_shell` |
| **System (10)** | `unraid_system_health`, `unraid_storage_status`, `unraid_disk_health`, `unraid_notifications_list`, `unraid_shares_list`, `unraid_share_inspect`, `unraid_files_inventory`, `unraid_system_connection_test`, `unraid_system_shell_readonly`, `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`).
Binary file not shown.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mua",
"version": "2026.08.24.r019",
"version": "2026.08.24.r020",
"description": "Mikes Unraid Agent - MCP over HTTP (Streamable HTTP) for Unraid",
"type": "module",
"main": "src/index.ts",
+9 -4
View File
@@ -2,13 +2,13 @@
<!DOCTYPE PLUGIN [
<!ENTITY name "mua">
<!ENTITY author "Michael">
<!ENTITY version "2026.08.24.r019">
<!ENTITY version "2026.08.24.r020">
<!ENTITY launch "Settings/mua">
<!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 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.24.r019-x86_64-1.txz">
<!ENTITY txzSHA256 "382435dbb98ae4818c4aced563f4247c6fca6b552d4a9de5695f9e4e98764d6c">
<!ENTITY txzURL "http://192.168.1.2:4000/michael/MUA-Mikes-Unraid-Agent/raw/branch/main/dist/mua-2026.08.24.r020-x86_64-1.txz">
<!ENTITY txzSHA256 "8172c825494216ca3c969816054483fd6fdca2c9dc18b54257061ec0be2d8048">
]>
<PLUGIN name="&name;"
@@ -23,6 +23,11 @@
>
<CHANGES>
### 2026.08.24.r020
- Neues begrenztes Nur-Lese-Werkzeug `unraid_files_inventory` erfasst Datei- und Ordnernamen eines Shares in einem Aufruf, ohne Dateiinhalte zu lesen.
- Ein Namensfilter findet gezielt Sammlungsordner und liefert deren Nachfahren; Medien- und Folgenprüfungen benötigen keine Kette aus wiederholten `ls`/`find`-Aufrufen mehr.
- Bestehende Freigaben der Nur-Lese-Shell erhalten das engere Inventarwerkzeug automatisch, ohne Schreibrechte hinzuzufügen.
### 2026.08.24.r019
- Ein gebündelter Docker-Updateablauf prüft echte Image-IDs, überspringt bereits aktuelle Images trotz veraltetem Unraid-Cache und erhält den ursprünglichen Laufzustand.
- Bis zu 25 ausdrücklich benannte Container werden in einem Werkzeugaufruf aktualisiert und anschließend kompakt auf Container-ID, Zustand, RestartCount und Health geprüft.
@@ -131,7 +136,7 @@ Das .txz enthält:
install/doinst.sh (läuft nach Installation)
===========================================
-->
<FILE Name="/boot/config/plugins/&name;/mua-2026.08.24.r019-x86_64-1.txz" Run="upgradepkg --install-new" Mode="755" Min="7.0.0">
<FILE Name="/boot/config/plugins/&name;/mua-2026.08.24.r020-x86_64-1.txz" Run="upgradepkg --install-new" Mode="755" Min="7.0.0">
<URL>&txzURL;</URL>
<SHA256>&txzSHA256;</SHA256>
</FILE>
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "mua",
"author": "Michael",
"version": "2026.08.24.r019",
"version": "2026.08.24.r020",
"minver": "7.0.0",
"pluginDirectory": "/usr/local/emhttp/plugins/mua",
"configDirectory": "/boot/config/plugins/mua",
+1
View File
@@ -74,6 +74,7 @@ $readonly = [
'unraid_system_shell_readonly', 'unraid_docker_update_status',
'unraid_system_health', 'unraid_storage_status', 'unraid_disk_health',
'unraid_notifications_list', 'unraid_shares_list', 'unraid_share_inspect',
'unraid_files_inventory',
];
$operator = array_merge($readonly, [
'unraid_network_audit_tcp', 'unraid_network_lan_probe',
+1 -1
View File
@@ -104,7 +104,7 @@ cat > "${BUILD_DIR}/install/slack-desc" << DESCEOF
|
|${PKG_NAME} - Mikes Unraid Agent
|MCP over HTTP (Streamable HTTP) Server für Unraid.
|34 Tools: Docker, Community Applications, Netzwerk und Unraid-System.
|35 Tools: Docker, Community Applications, Netzwerk und Unraid-System.
|Port: 3002, Endpunkt: /mcp
|
|Runtime: TypeScript (Bun Runtime, kompiliertes Binary)
+95
View File
@@ -234,6 +234,96 @@ function shares_list(?string $requested): array {
return ['schema_version' => '1.0', 'share_count' => count($items), 'shares' => $items];
}
function files_inventory(
string $share,
string $relative_path,
string $name_contains,
int $max_depth,
int $max_entries,
bool $include_files,
bool $include_directories
): array {
$known = [];
foreach (ini_sections('/var/local/emhttp/shares.ini') as $section => $entry) {
$name = (string)($entry['name'] ?? trim($section, '"'));
if ($name !== '') $known[$name] = true;
}
if (!isset($known[$share])) fail_status('Unknown share name');
if ($relative_path !== '' && ($relative_path[0] === '/' || str_contains($relative_path, "\0"))) {
fail_status('relative_path must stay below the selected share');
}
$parts = array_values(array_filter(explode('/', str_replace('\\', '/', $relative_path)), fn($p) => $p !== ''));
if (in_array('..', $parts, true) || in_array('.', $parts, true)) {
fail_status('relative_path traversal is not allowed');
}
$relative_path = implode('/', $parts);
$root = '/mnt/user/' . $share . ($relative_path !== '' ? '/' . $relative_path : '');
if (!is_dir($root) || !is_readable($root)) fail_status('Selected inventory root is not a readable directory');
$max_depth = max(1, min(20, $max_depth));
$max_entries = max(1, min(5000, $max_entries));
$needle = mb_strtolower(trim($name_contains));
$queue = [[$root, '', 0, $needle === '']];
$entries = [];
$scanned = 0;
$max_scanned = max(1000, min(100000, $max_entries * 50));
$truncated = false;
while ($queue) {
[$directory, $directory_relative, $depth, $inside_match] = array_shift($queue);
if ($depth >= $max_depth) continue;
$children = @scandir($directory);
if (!is_array($children)) continue;
natcasesort($children);
foreach ($children as $name) {
if ($name === '.' || $name === '..') continue;
$full = $directory . '/' . $name;
$relative = $directory_relative === '' ? $name : $directory_relative . '/' . $name;
$is_link = is_link($full);
$is_dir = !$is_link && is_dir($full);
$matches = $needle === '' || mb_stripos($relative, $needle) !== false;
$selected = $inside_match || $matches;
$scanned++;
if ($scanned > $max_scanned) {
$truncated = true;
break 2;
}
if ($selected && (($is_dir && $include_directories) || (!$is_dir && $include_files))) {
$extension = $is_dir ? '' : strtolower((string)pathinfo($name, PATHINFO_EXTENSION));
$entries[] = [
'relative_path' => $relative,
'name' => $name,
'type' => $is_link ? 'symlink' : ($is_dir ? 'directory' : 'file'),
'extension' => $extension,
'size_bytes' => (!$is_dir && !$is_link) ? max(0, (int)@filesize($full)) : 0,
'depth' => $depth + 1,
];
if (count($entries) >= $max_entries) {
$truncated = true;
break 2;
}
}
if ($is_dir) $queue[] = [$full, $relative, $depth + 1, $inside_match || $matches];
}
}
return [
'schema_version' => '1.0',
'share' => $share,
'inventory_root' => $relative_path,
'name_contains' => $name_contains,
'entry_count' => count($entries),
'scanned_entries' => $scanned,
'truncated' => $truncated,
'max_depth' => $max_depth,
'max_entries' => $max_entries,
'max_scanned_entries' => $max_scanned,
'content_read' => false,
'entries' => $entries,
];
}
function docker_update_state_value(mixed $value): ?string {
if (is_array($value)) {
foreach (['update_available', 'updateAvailable'] as $field) {
@@ -356,6 +446,11 @@ if (realpath($_SERVER['SCRIPT_FILENAME'] ?? '') === __FILE__) {
case 'notifications': respond(notification_list((int)($argv[2] ?? 20), $argv[3] ?? 'all')); break;
case 'shares-list': respond(shares_list(null)); break;
case 'share-inspect': respond(shares_list($argv[2] ?? '')); break;
case 'files-inventory': respond(files_inventory(
$argv[2] ?? '', $argv[3] ?? '', $argv[4] ?? '',
(int)($argv[5] ?? 10), (int)($argv[6] ?? 2000),
($argv[7] ?? '1') !== '0', ($argv[8] ?? '1') !== '0'
)); break;
case 'docker-update-status': respond(docker_update_status()); break;
default: fail_status('Unknown read-only status action');
}
+12
View File
@@ -57,6 +57,7 @@ export const SAFE_DEFAULT_TOOLS = [
"unraid_notifications_list",
"unraid_shares_list",
"unraid_share_inspect",
"unraid_files_inventory",
];
// ── Config laden ────────────────────────────────────────────────────────
@@ -104,6 +105,17 @@ export function parseConfig(content: string): MUAConfig {
) {
cfg.enabledTools.push("unraid_docker_update_verified_batch");
}
// r020 adds a bounded metadata-only filesystem inventory. It is strictly
// less powerful than the already-enabled read-only shell and avoids many
// repeated ls/find calls, so existing read-only installations may receive
// it without granting a new write capability.
if (
!cfg.allToolsEnabled
&& cfg.enabledTools.includes("unraid_system_shell_readonly")
&& !cfg.enabledTools.includes("unraid_files_inventory")
) {
cfg.enabledTools.push("unraid_files_inventory");
}
return cfg;
}
+1 -1
View File
@@ -14,7 +14,7 @@ import { createHash, randomUUID } from "node:crypto";
// ── Konstanten ──────────────────────────────────────────────────────────
export const MUA_SERVER_NAME = "mua";
export const MUA_VERSION = "2026.08.24.r019";
export const MUA_VERSION = "2026.08.24.r020";
export const MUA_PROTOCOL_VERSION = "2025-03-26";
export const PHP_HELPER = "/usr/local/bin/unraid-docker-mcp-helper.php";
export const STATUS_HELPER = "/usr/local/bin/unraid-mcp-status-helper.php";
+9
View File
@@ -30,6 +30,14 @@ describe("secure tool configuration", () => {
expect(cfg.enabledTools).toContain("unraid_docker_update");
expect(cfg.enabledTools).toContain("unraid_docker_update_verified_batch");
});
test("an enabled read-only shell also exposes bounded file inventory", () => {
const cfg = parseConfig(
"MUA_API_KEY=test\nMUA_ENABLED_TOOLS=unraid_system_shell_readonly\n",
);
expect(cfg.enabledTools).toContain("unraid_files_inventory");
expect(cfg.enabledTools).not.toContain("unraid_system_shell");
});
});
describe("secret handling", () => {
@@ -53,6 +61,7 @@ describe("risk classification", () => {
expect(getToolRisk("unraid_network_lan_probe")).toBe("active");
expect(getToolRisk("unraid_docker_list")).toBe("read");
expect(getToolRisk("unraid_system_shell_readonly")).toBe("read");
expect(getToolRisk("unraid_files_inventory")).toBe("read");
expect(getToolRisk("unraid_system_health")).toBe("read");
expect(getToolRisk("unraid_ca_search")).toBe("active");
expect(getToolRisk("unraid_ca_install_preview")).toBe("active");
+29
View File
@@ -533,6 +533,35 @@ export const TOOLS: ToolDef[] = [
},
handler: (a) => runStatusHelper("share-inspect", String(a["share"] ?? "")),
},
{
name: "unraid_files_inventory",
description:
"Inventory file and directory names below one exact Unraid share in one bounded read-only call. Use this instead of repeated ls/find calls for media-library audits, missing-episode checks and locating a named collection. It never reads file contents. If name_contains matches a directory, that directory and its descendants are returned; unrelated trees are omitted.",
inputSchema: {
type: "object",
properties: {
share: str("Exact Unraid share name, for example Audiobooks"),
relative_path: str("Optional path below the share; never use /mnt/user or an absolute path"),
name_contains: str("Optional case-insensitive name fragment, for example 'drei'. Matching directories include their descendants"),
max_depth: int("Maximum directory depth below relative_path (1-20, default 10)"),
max_entries: int("Maximum returned entries (1-5000, default 2000)"),
include_files: { type: "boolean", description: "Include files (default true)" },
include_directories: { type: "boolean", description: "Include directories (default true)" },
},
required: ["share"],
additionalProperties: false,
},
handler: (a) => runStatusHelper(
"files-inventory",
String(a["share"] ?? ""),
String(a["relative_path"] ?? ""),
String(a["name_contains"] ?? ""),
String(Math.max(1, Math.min(20, Number(a["max_depth"] ?? 10)))),
String(Math.max(1, Math.min(5000, Number(a["max_entries"] ?? 2000)))),
a["include_files"] === false ? "0" : "1",
a["include_directories"] === false ? "0" : "1",
),
},
{
name: "unraid_system_connection_test",
description: