feat: add bounded file inventory for media audits
This commit is contained in:
@@ -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`).
|
||||
|
||||
BIN
Binary file not shown.
+1
-1
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user